Start implementing monthly digest feature

This commit is contained in:
Eugene Burmakin 2025-10-22 20:15:22 +02:00
parent 4677bcc698
commit 66da25b886
18 changed files with 2531 additions and 0 deletions

1728
MONTHLY_DIGEST_PLAN.md Normal file

File diff suppressed because it is too large Load diff

View 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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View 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