mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 17:51:39 -05:00
Start implementing monthly digest feature
This commit is contained in:
parent
4677bcc698
commit
66da25b886
18 changed files with 2531 additions and 0 deletions
1728
MONTHLY_DIGEST_PLAN.md
Normal file
1728
MONTHLY_DIGEST_PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
31
app/mailers/digest_mailer.rb
Normal file
31
app/mailers/digest_mailer.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DigestMailer < ApplicationMailer
|
||||
def monthly_digest(user, year, month)
|
||||
@user = user
|
||||
@year = year
|
||||
@month = month
|
||||
@period_type = :monthly
|
||||
@digest_data = Digests::Calculator.new(user, period: :monthly, year: year, month: month).call
|
||||
|
||||
return if @digest_data.nil? # Don't send if calculation failed
|
||||
|
||||
mail(
|
||||
to: user.email,
|
||||
subject: "#{Date::MONTHNAMES[month]} #{year} - Your Location Recap"
|
||||
)
|
||||
end
|
||||
|
||||
# Future: yearly_digest method
|
||||
# def yearly_digest(user, year)
|
||||
# @user = user
|
||||
# @year = year
|
||||
# @period_type = :yearly
|
||||
# @digest_data = Digests::Calculator.new(user, period: :yearly, year: year).call
|
||||
#
|
||||
# mail(
|
||||
# to: user.email,
|
||||
# subject: "#{year} Year in Review - Your Location Recap"
|
||||
# )
|
||||
# end
|
||||
end
|
||||
|
|
@ -145,6 +145,32 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
|
||||
# Digest preferences methods
|
||||
def digest_enabled?(period = :monthly)
|
||||
settings.dig('digest_preferences', period.to_s, 'enabled') || false
|
||||
end
|
||||
|
||||
def enable_digest!(period = :monthly)
|
||||
prefs = settings['digest_preferences'] || {}
|
||||
prefs[period.to_s] ||= {}
|
||||
prefs[period.to_s]['enabled'] = true
|
||||
update!(settings: settings.merge('digest_preferences' => prefs))
|
||||
end
|
||||
|
||||
def disable_digest!(period = :monthly)
|
||||
prefs = settings['digest_preferences'] || {}
|
||||
prefs[period.to_s] ||= {}
|
||||
prefs[period.to_s]['enabled'] = false
|
||||
update!(settings: settings.merge('digest_preferences' => prefs))
|
||||
end
|
||||
|
||||
def digest_last_sent_at(period = :monthly)
|
||||
timestamp = settings.dig('digest_preferences', period.to_s, 'last_sent_at')
|
||||
Time.zone.parse(timestamp) if timestamp.present?
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_api_key
|
||||
|
|
|
|||
78
app/services/digests/calculator.rb
Normal file
78
app/services/digests/calculator.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Calculator
|
||||
def initialize(user, period:, year:, month: nil)
|
||||
@user = user
|
||||
@period = period # :monthly or :yearly
|
||||
@year = year
|
||||
@month = month
|
||||
@date_range = build_date_range
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
period_type: @period,
|
||||
year: @year,
|
||||
month: @month,
|
||||
period_label: period_label,
|
||||
overview: overview_data,
|
||||
distance_stats: distance_stats,
|
||||
top_cities: top_cities,
|
||||
visited_places: visited_places,
|
||||
trips: trips_data,
|
||||
all_time_stats: all_time_stats
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Digest calculation failed: #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_date_range
|
||||
case @period
|
||||
when :monthly
|
||||
start_date = Date.new(@year, @month, 1).beginning_of_day
|
||||
end_date = start_date.end_of_month.end_of_day
|
||||
start_date..end_date
|
||||
when :yearly
|
||||
start_date = Date.new(@year, 1, 1).beginning_of_day
|
||||
end_date = start_date.end_of_year.end_of_day
|
||||
start_date..end_date
|
||||
end
|
||||
end
|
||||
|
||||
def period_label
|
||||
case @period
|
||||
when :monthly
|
||||
"#{Date::MONTHNAMES[@month]} #{@year}"
|
||||
when :yearly
|
||||
"#{@year}"
|
||||
end
|
||||
end
|
||||
|
||||
def overview_data
|
||||
Digests::Queries::Overview.new(@user, @date_range).call
|
||||
end
|
||||
|
||||
def distance_stats
|
||||
Digests::Queries::Distance.new(@user, @date_range).call
|
||||
end
|
||||
|
||||
def top_cities
|
||||
Digests::Queries::Cities.new(@user, @date_range).call
|
||||
end
|
||||
|
||||
def visited_places
|
||||
Digests::Queries::Places.new(@user, @date_range).call
|
||||
end
|
||||
|
||||
def trips_data
|
||||
Digests::Queries::Trips.new(@user, @date_range).call
|
||||
end
|
||||
|
||||
def all_time_stats
|
||||
Digests::Queries::AllTime.new(@user).call
|
||||
end
|
||||
end
|
||||
34
app/services/digests/queries/all_time.rb
Normal file
34
app/services/digests/queries/all_time.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Queries::AllTime
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
total_countries: @user.points.where.not(country_name: nil).distinct.count(:country_name),
|
||||
total_cities: @user.points.where.not(city: nil).distinct.count(:city),
|
||||
total_places: @user.visits.joins(:area).distinct.count('areas.id'),
|
||||
total_distance_km: calculate_total_distance,
|
||||
account_age_days: account_age_days,
|
||||
first_point_date: first_point_date
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_total_distance
|
||||
# Use cached stat data if available, otherwise calculate
|
||||
@user.stats.sum(:distance) || 0
|
||||
end
|
||||
|
||||
def account_age_days
|
||||
(Date.today - @user.created_at.to_date).to_i
|
||||
end
|
||||
|
||||
def first_point_date
|
||||
first_point = @user.points.order(timestamp: :asc).first
|
||||
first_point ? Time.at(first_point.timestamp).to_date : nil
|
||||
end
|
||||
end
|
||||
22
app/services/digests/queries/cities.rb
Normal file
22
app/services/digests/queries/cities.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Queries::Cities
|
||||
def initialize(user, date_range, limit: 5)
|
||||
@user = user
|
||||
@date_range = date_range
|
||||
@limit = limit
|
||||
@start_timestamp = date_range.begin.to_i
|
||||
@end_timestamp = date_range.end.to_i
|
||||
end
|
||||
|
||||
def call
|
||||
@user.points
|
||||
.where(timestamp: @start_timestamp..@end_timestamp)
|
||||
.where.not(city: nil)
|
||||
.group(:city)
|
||||
.count
|
||||
.sort_by { |_city, count| -count }
|
||||
.first(@limit)
|
||||
.map { |city, count| { name: city, visits: count } }
|
||||
end
|
||||
end
|
||||
59
app/services/digests/queries/distance.rb
Normal file
59
app/services/digests/queries/distance.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Queries::Distance
|
||||
def initialize(user, date_range)
|
||||
@user = user
|
||||
@date_range = date_range
|
||||
@start_timestamp = date_range.begin.to_i
|
||||
@end_timestamp = date_range.end.to_i
|
||||
end
|
||||
|
||||
def call
|
||||
points = fetch_points
|
||||
|
||||
{
|
||||
total_distance_km: calculate_total_distance(points),
|
||||
daily_average_km: calculate_daily_average(points),
|
||||
max_distance_day: find_max_distance_day(points)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_points
|
||||
@user.points
|
||||
.where(timestamp: @start_timestamp..@end_timestamp)
|
||||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def calculate_total_distance(points)
|
||||
return 0 if points.empty?
|
||||
|
||||
total = 0
|
||||
points.each_cons(2) do |p1, p2|
|
||||
total += Geocoder::Calculations.distance_between(
|
||||
[p1.latitude, p1.longitude],
|
||||
[p2.latitude, p2.longitude],
|
||||
units: :km
|
||||
)
|
||||
end
|
||||
total.round(2)
|
||||
end
|
||||
|
||||
def calculate_daily_average(points)
|
||||
total = calculate_total_distance(points)
|
||||
days = (@date_range.end.to_date - @date_range.begin.to_date).to_i + 1
|
||||
(total / days).round(2)
|
||||
rescue ZeroDivisionError
|
||||
0
|
||||
end
|
||||
|
||||
def find_max_distance_day(points)
|
||||
# Group by day and calculate distance for each day
|
||||
daily_distances = points.group_by { |p| Time.at(p.timestamp).to_date }
|
||||
.transform_values { |day_points| calculate_total_distance(day_points) }
|
||||
|
||||
max_day = daily_distances.max_by { |_date, distance| distance }
|
||||
max_day ? { date: max_day[0], distance_km: max_day[1] } : nil
|
||||
end
|
||||
end
|
||||
51
app/services/digests/queries/overview.rb
Normal file
51
app/services/digests/queries/overview.rb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Queries::Overview
|
||||
def initialize(user, date_range)
|
||||
@user = user
|
||||
@date_range = date_range
|
||||
@start_timestamp = date_range.begin.to_i
|
||||
@end_timestamp = date_range.end.to_i
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
countries_count: count_countries,
|
||||
cities_count: count_cities,
|
||||
places_count: count_places,
|
||||
points_count: count_points
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_countries
|
||||
@user.points
|
||||
.where(timestamp: @start_timestamp..@end_timestamp)
|
||||
.where.not(country_name: nil)
|
||||
.distinct
|
||||
.count(:country_name)
|
||||
end
|
||||
|
||||
def count_cities
|
||||
@user.points
|
||||
.where(timestamp: @start_timestamp..@end_timestamp)
|
||||
.where.not(city: nil)
|
||||
.distinct
|
||||
.count(:city)
|
||||
end
|
||||
|
||||
def count_places
|
||||
@user.visits
|
||||
.joins(:area)
|
||||
.where(started_at: @date_range)
|
||||
.distinct
|
||||
.count('areas.id')
|
||||
end
|
||||
|
||||
def count_points
|
||||
@user.points
|
||||
.where(timestamp: @start_timestamp..@end_timestamp)
|
||||
.count
|
||||
end
|
||||
end
|
||||
26
app/services/digests/queries/places.rb
Normal file
26
app/services/digests/queries/places.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Queries::Places
|
||||
def initialize(user, date_range, limit: 3)
|
||||
@user = user
|
||||
@date_range = date_range
|
||||
@limit = limit
|
||||
end
|
||||
|
||||
def call
|
||||
@user.visits
|
||||
.joins(:area)
|
||||
.where(started_at: @date_range)
|
||||
.select('visits.*, areas.name as area_name, EXTRACT(EPOCH FROM (visits.ended_at - visits.started_at)) as duration_seconds')
|
||||
.order('duration_seconds DESC')
|
||||
.limit(@limit)
|
||||
.map do |visit|
|
||||
{
|
||||
name: visit.area_name,
|
||||
duration_hours: (visit.duration_seconds / 3600.0).round(1),
|
||||
started_at: visit.started_at,
|
||||
ended_at: visit.ended_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
25
app/services/digests/queries/trips.rb
Normal file
25
app/services/digests/queries/trips.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Digests::Queries::Trips
|
||||
def initialize(user, date_range)
|
||||
@user = user
|
||||
@date_range = date_range
|
||||
end
|
||||
|
||||
def call
|
||||
@user.trips
|
||||
.where('started_at <= ? AND ended_at >= ?', @date_range.end, @date_range.begin)
|
||||
.order(started_at: :desc)
|
||||
.map do |trip|
|
||||
{
|
||||
id: trip.id,
|
||||
name: trip.name,
|
||||
started_at: trip.started_at,
|
||||
ended_at: trip.ended_at,
|
||||
distance_km: trip.distance || 0,
|
||||
countries: trip.visited_countries || [],
|
||||
photo_previews: trip.photo_previews.first(3)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
13
app/views/digest_mailer/monthly_digest.html.erb
Normal file
13
app/views/digest_mailer/monthly_digest.html.erb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Monthly Digest Placeholder</h1>
|
||||
<p>Hello <%= @user.email %></p>
|
||||
<p>This is a placeholder for <%= Date::MONTHNAMES[@month] %> <%= @year %></p>
|
||||
<!-- Real content will be added in Phase 3 -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -175,4 +175,10 @@ Rails.application.routes.draw do
|
|||
post 'subscriptions/callback', to: 'subscriptions#callback'
|
||||
end
|
||||
end
|
||||
|
||||
# Digest routes
|
||||
namespace :digests do
|
||||
get 'preview/:period/:year(/:month)', to: 'digests#preview', as: :preview
|
||||
post 'send_test/:period', to: 'digests#send_test', as: :send_test
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -335,5 +335,116 @@ RSpec.describe User, type: :model do
|
|||
expect(user.timezone).to eq(Time.zone.name)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'digest preferences' do
|
||||
let(:user) { create(:user, settings: {}) }
|
||||
|
||||
describe '#digest_enabled?' do
|
||||
context 'when digest preferences not set' do
|
||||
it 'returns false' do
|
||||
expect(user.digest_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when monthly digest is enabled' do
|
||||
before do
|
||||
user.update!(settings: {
|
||||
'digest_preferences' => {
|
||||
'monthly' => { 'enabled' => true }
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(user.digest_enabled?(:monthly)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when monthly digest is disabled' do
|
||||
before do
|
||||
user.update!(settings: {
|
||||
'digest_preferences' => {
|
||||
'monthly' => { 'enabled' => false }
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(user.digest_enabled?(:monthly)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enable_digest!' do
|
||||
it 'enables the digest for given period' do
|
||||
expect { user.enable_digest!(:monthly) }
|
||||
.to change { user.reload.digest_enabled?(:monthly) }
|
||||
.from(false).to(true)
|
||||
end
|
||||
|
||||
it 'preserves other settings' do
|
||||
user.update!(settings: { 'other_setting' => 'value' })
|
||||
user.enable_digest!(:monthly)
|
||||
|
||||
expect(user.settings['other_setting']).to eq('value')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disable_digest!' do
|
||||
before do
|
||||
user.enable_digest!(:monthly)
|
||||
end
|
||||
|
||||
it 'disables the digest for given period' do
|
||||
expect { user.disable_digest!(:monthly) }
|
||||
.to change { user.reload.digest_enabled?(:monthly) }
|
||||
.from(true).to(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#digest_last_sent_at' do
|
||||
context 'when never sent' do
|
||||
it 'returns nil' do
|
||||
expect(user.digest_last_sent_at(:monthly)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when previously sent' do
|
||||
let(:sent_time) { Time.zone.parse('2024-01-01 09:00:00') }
|
||||
|
||||
before do
|
||||
user.update!(settings: {
|
||||
'digest_preferences' => {
|
||||
'monthly' => {
|
||||
'enabled' => true,
|
||||
'last_sent_at' => sent_time.iso8601
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns the last sent time' do
|
||||
expect(user.digest_last_sent_at(:monthly)).to be_within(1.second).of(sent_time)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timestamp is invalid' do
|
||||
before do
|
||||
user.update!(settings: {
|
||||
'digest_preferences' => {
|
||||
'monthly' => {
|
||||
'enabled' => true,
|
||||
'last_sent_at' => 'invalid'
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(user.digest_last_sent_at(:monthly)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
102
spec/services/digests/calculator_spec.rb
Normal file
102
spec/services/digests/calculator_spec.rb
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Digests::Calculator do
|
||||
let(:user) { create(:user) }
|
||||
let(:year) { 2024 }
|
||||
let(:month) { 12 }
|
||||
|
||||
describe '#call' do
|
||||
context 'for monthly digest' do
|
||||
subject(:calculator) { described_class.new(user, period: :monthly, year: year, month: month) }
|
||||
|
||||
it 'returns a hash with all required keys' do
|
||||
result = calculator.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result.keys).to match_array(%i[
|
||||
period_type year month period_label overview distance_stats
|
||||
top_cities visited_places trips all_time_stats
|
||||
])
|
||||
end
|
||||
|
||||
it 'sets period_type to monthly' do
|
||||
result = calculator.call
|
||||
expect(result[:period_type]).to eq(:monthly)
|
||||
end
|
||||
|
||||
it 'sets correct year and month' do
|
||||
result = calculator.call
|
||||
expect(result[:year]).to eq(year)
|
||||
expect(result[:month]).to eq(month)
|
||||
end
|
||||
|
||||
it 'generates correct period_label' do
|
||||
result = calculator.call
|
||||
expect(result[:period_label]).to eq('December 2024')
|
||||
end
|
||||
|
||||
context 'when error occurs' do
|
||||
before do
|
||||
allow_any_instance_of(Digests::Queries::Overview).to receive(:call).and_raise(StandardError, 'Test error')
|
||||
end
|
||||
|
||||
it 'returns nil and logs error' do
|
||||
expect(calculator.call).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for yearly digest' do
|
||||
subject(:calculator) { described_class.new(user, period: :yearly, year: year) }
|
||||
|
||||
it 'generates correct period_label' do
|
||||
result = calculator.call
|
||||
expect(result[:period_label]).to eq('2024')
|
||||
end
|
||||
|
||||
it 'sets period_type to yearly' do
|
||||
result = calculator.call
|
||||
expect(result[:period_type]).to eq(:yearly)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with actual data' do
|
||||
let!(:points) do
|
||||
3.times.map do |i|
|
||||
create(:point,
|
||||
user: user,
|
||||
timestamp: Time.new(2024, 12, 15, 12, i).to_i,
|
||||
city: 'Berlin',
|
||||
country_name: 'Germany')
|
||||
end
|
||||
end
|
||||
|
||||
subject(:calculator) { described_class.new(user, period: :monthly, year: year, month: month) }
|
||||
|
||||
it 'includes overview data' do
|
||||
result = calculator.call
|
||||
expect(result[:overview]).to be_a(Hash)
|
||||
expect(result[:overview][:points_count]).to eq(3)
|
||||
end
|
||||
|
||||
it 'includes distance stats' do
|
||||
result = calculator.call
|
||||
expect(result[:distance_stats]).to be_a(Hash)
|
||||
expect(result[:distance_stats]).to have_key(:total_distance_km)
|
||||
end
|
||||
|
||||
it 'includes top cities' do
|
||||
result = calculator.call
|
||||
expect(result[:top_cities]).to be_an(Array)
|
||||
end
|
||||
|
||||
it 'includes all time stats' do
|
||||
result = calculator.call
|
||||
expect(result[:all_time_stats]).to be_a(Hash)
|
||||
expect(result[:all_time_stats][:total_countries]).to be >= 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
58
spec/services/digests/queries/all_time_spec.rb
Normal file
58
spec/services/digests/queries/all_time_spec.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Digests::Queries::AllTime do
|
||||
let(:user) { create(:user) }
|
||||
subject(:query) { described_class.new(user) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with no data' do
|
||||
it 'returns zero counts' do
|
||||
result = query.call
|
||||
|
||||
expect(result[:total_countries]).to eq(0)
|
||||
expect(result[:total_cities]).to eq(0)
|
||||
expect(result[:total_places]).to eq(0)
|
||||
expect(result[:total_distance_km]).to eq(0)
|
||||
expect(result[:first_point_date]).to be_nil
|
||||
end
|
||||
|
||||
it 'calculates account age' do
|
||||
result = query.call
|
||||
expect(result[:account_age_days]).to be >= 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'with data' do
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-01-15 10:00').to_i, city: 'Berlin', country_name: 'Germany'),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-06-15 10:00').to_i, city: 'Paris', country_name: 'France')
|
||||
]
|
||||
end
|
||||
|
||||
let!(:stat) { create(:stat, user: user, year: 2024, month: 1, distance: 100) }
|
||||
|
||||
it 'counts total countries' do
|
||||
result = query.call
|
||||
expect(result[:total_countries]).to eq(2)
|
||||
end
|
||||
|
||||
it 'counts total cities' do
|
||||
result = query.call
|
||||
expect(result[:total_cities]).to eq(2)
|
||||
end
|
||||
|
||||
it 'sums distance from stats' do
|
||||
result = query.call
|
||||
expect(result[:total_distance_km]).to eq(100)
|
||||
end
|
||||
|
||||
it 'finds first point date' do
|
||||
result = query.call
|
||||
expect(result[:first_point_date]).to eq(Date.new(2024, 1, 15))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
46
spec/services/digests/queries/cities_spec.rb
Normal file
46
spec/services/digests/queries/cities_spec.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Digests::Queries::Cities do
|
||||
let(:user) { create(:user) }
|
||||
let(:date_range) { Time.zone.parse('2024-12-01')..Time.zone.parse('2024-12-31').end_of_day }
|
||||
subject(:query) { described_class.new(user, date_range) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with no points' do
|
||||
it 'returns empty array' do
|
||||
result = query.call
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points in multiple cities' do
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 10:00').to_i, city: 'Berlin'),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 14:00').to_i, city: 'Berlin'),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-16 10:00').to_i, city: 'Hamburg'),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-17 10:00').to_i, city: 'Berlin')
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns cities sorted by visit count' do
|
||||
result = query.call
|
||||
|
||||
expect(result).to be_an(Array)
|
||||
expect(result.first[:name]).to eq('Berlin')
|
||||
expect(result.first[:visits]).to eq(3)
|
||||
expect(result.second[:name]).to eq('Hamburg')
|
||||
expect(result.second[:visits]).to eq(1)
|
||||
end
|
||||
|
||||
it 'respects the limit parameter' do
|
||||
limited_query = described_class.new(user, date_range, limit: 1)
|
||||
result = limited_query.call
|
||||
|
||||
expect(result.length).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
48
spec/services/digests/queries/distance_spec.rb
Normal file
48
spec/services/digests/queries/distance_spec.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Digests::Queries::Distance do
|
||||
let(:user) { create(:user) }
|
||||
let(:date_range) { Time.zone.parse('2024-12-01')..Time.zone.parse('2024-12-31').end_of_day }
|
||||
subject(:query) { described_class.new(user, date_range) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with no points' do
|
||||
it 'returns zero distance' do
|
||||
result = query.call
|
||||
|
||||
expect(result[:total_distance_km]).to eq(0)
|
||||
expect(result[:daily_average_km]).to eq(0)
|
||||
expect(result[:max_distance_day]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points' do
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 10:00').to_i, latitude: 52.52, longitude: 13.405),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 14:00').to_i, latitude: 52.51, longitude: 13.395),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-16 10:00').to_i, latitude: 52.50, longitude: 13.385)
|
||||
]
|
||||
end
|
||||
|
||||
it 'calculates total distance' do
|
||||
result = query.call
|
||||
expect(result[:total_distance_km]).to be > 0
|
||||
end
|
||||
|
||||
it 'calculates daily average' do
|
||||
result = query.call
|
||||
expect(result[:daily_average_km]).to be >= 0
|
||||
end
|
||||
|
||||
it 'finds max distance day' do
|
||||
result = query.call
|
||||
expect(result[:max_distance_day]).to be_a(Hash)
|
||||
expect(result[:max_distance_day][:date]).to be_a(Date)
|
||||
expect(result[:max_distance_day][:distance_km]).to be > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
67
spec/services/digests/queries/overview_spec.rb
Normal file
67
spec/services/digests/queries/overview_spec.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Digests::Queries::Overview do
|
||||
let(:user) { create(:user) }
|
||||
let(:date_range) { Time.zone.parse('2024-12-01')..Time.zone.parse('2024-12-31').end_of_day }
|
||||
subject(:query) { described_class.new(user, date_range) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with no data' do
|
||||
it 'returns zero counts' do
|
||||
result = query.call
|
||||
|
||||
expect(result[:countries_count]).to eq(0)
|
||||
expect(result[:cities_count]).to eq(0)
|
||||
expect(result[:places_count]).to eq(0)
|
||||
expect(result[:points_count]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points in date range' do
|
||||
let!(:points_in_range) do
|
||||
[
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 12:00').to_i, city: 'Berlin', country_name: 'Germany'),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-16 12:00').to_i, city: 'Hamburg', country_name: 'Germany'),
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-12-17 12:00').to_i, city: 'Berlin', country_name: 'Germany')
|
||||
]
|
||||
end
|
||||
|
||||
let!(:points_outside_range) do
|
||||
create(:point, user: user, timestamp: Time.zone.parse('2024-11-15 12:00').to_i, city: 'Paris', country_name: 'France')
|
||||
end
|
||||
|
||||
it 'counts only points in range' do
|
||||
result = query.call
|
||||
expect(result[:points_count]).to eq(3)
|
||||
end
|
||||
|
||||
it 'counts distinct countries' do
|
||||
result = query.call
|
||||
expect(result[:countries_count]).to eq(1)
|
||||
end
|
||||
|
||||
it 'counts distinct cities' do
|
||||
result = query.call
|
||||
expect(result[:cities_count]).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with visits and areas' do
|
||||
let(:area) { create(:area, user: user) }
|
||||
let!(:visit) do
|
||||
create(:visit,
|
||||
user: user,
|
||||
area: area,
|
||||
started_at: Time.zone.parse('2024-12-15 12:00'),
|
||||
ended_at: Time.zone.parse('2024-12-15 14:00'))
|
||||
end
|
||||
|
||||
it 'counts places' do
|
||||
result = query.call
|
||||
expect(result[:places_count]).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue