From c2ba645642e367b68f34072ffda6bdc0bfef5bd0 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 27 Dec 2025 14:57:55 +0100 Subject: [PATCH] Add yearly digest --- Gemfile | 1 + Gemfile.lock | 3 + app/controllers/settings_controller.rb | 2 +- .../shared/yearly_digests_controller.rb | 54 +++ app/controllers/yearly_digests_controller.rb | 52 +++ app/helpers/yearly_digests_helper.rb | 51 +++ app/jobs/yearly_digests/calculating_job.rb | 26 ++ app/jobs/yearly_digests/email_sending_job.rb | 31 ++ .../yearly_digests/year_end_scheduling_job.rb | 20 + app/mailers/yearly_digests_mailer.rb | 36 ++ app/models/user.rb | 1 + app/models/yearly_digest.rb | 156 +++++++ app/services/users/safe_settings.rb | 7 +- app/services/yearly_digests/calculate_year.rb | 131 ++++++ .../yearly_digests/chart_image_generator.rb | 40 ++ .../first_time_visits_calculator.rb | 75 +++ .../year_over_year_calculator.rb | 77 ++++ app/views/settings/index.html.erb | 21 + app/views/stats/index.html.erb | 7 + app/views/yearly_digests/chart.html.erb | 75 +++ app/views/yearly_digests/index.html.erb | 91 ++++ app/views/yearly_digests/public_year.html.erb | 183 ++++++++ app/views/yearly_digests/show.html.erb | 313 +++++++++++++ .../year_end_digest.html.erb | 254 +++++++++++ .../year_end_digest.text.erb | 41 ++ config/initializers/grover.rb | 10 + config/routes.rb | 9 + config/schedule.yml | 5 + config/sidekiq.yml | 1 + db/migrate/20251227000001_create_digests.rb | 38 ++ db/schema.rb | 26 +- spec/factories/yearly_digests.rb | 133 ++++++ .../yearly_digests/calculating_job_spec.rb | 49 ++ .../yearly_digests/email_sending_job_spec.rb | 83 ++++ .../year_end_scheduling_job_spec.rb | 110 +++++ spec/models/yearly_digest_spec.rb | 429 ++++++++++++++++++ spec/requests/shared/yearly_digests_spec.rb | 137 ++++++ spec/requests/yearly_digests_spec.rb | 141 ++++++ .../yearly_digests/calculate_year_spec.rb | 136 ++++++ .../first_time_visits_calculator_spec.rb | 104 +++++ .../year_over_year_calculator_spec.rb | 116 +++++ 41 files changed, 3272 insertions(+), 3 deletions(-) create mode 100644 app/controllers/shared/yearly_digests_controller.rb create mode 100644 app/controllers/yearly_digests_controller.rb create mode 100644 app/helpers/yearly_digests_helper.rb create mode 100644 app/jobs/yearly_digests/calculating_job.rb create mode 100644 app/jobs/yearly_digests/email_sending_job.rb create mode 100644 app/jobs/yearly_digests/year_end_scheduling_job.rb create mode 100644 app/mailers/yearly_digests_mailer.rb create mode 100644 app/models/yearly_digest.rb create mode 100644 app/services/yearly_digests/calculate_year.rb create mode 100644 app/services/yearly_digests/chart_image_generator.rb create mode 100644 app/services/yearly_digests/first_time_visits_calculator.rb create mode 100644 app/services/yearly_digests/year_over_year_calculator.rb create mode 100644 app/views/yearly_digests/chart.html.erb create mode 100644 app/views/yearly_digests/index.html.erb create mode 100644 app/views/yearly_digests/public_year.html.erb create mode 100644 app/views/yearly_digests/show.html.erb create mode 100644 app/views/yearly_digests_mailer/year_end_digest.html.erb create mode 100644 app/views/yearly_digests_mailer/year_end_digest.text.erb create mode 100644 config/initializers/grover.rb create mode 100644 db/migrate/20251227000001_create_digests.rb create mode 100644 spec/factories/yearly_digests.rb create mode 100644 spec/jobs/yearly_digests/calculating_job_spec.rb create mode 100644 spec/jobs/yearly_digests/email_sending_job_spec.rb create mode 100644 spec/jobs/yearly_digests/year_end_scheduling_job_spec.rb create mode 100644 spec/models/yearly_digest_spec.rb create mode 100644 spec/requests/shared/yearly_digests_spec.rb create mode 100644 spec/requests/yearly_digests_spec.rb create mode 100644 spec/services/yearly_digests/calculate_year_spec.rb create mode 100644 spec/services/yearly_digests/first_time_visits_calculator_spec.rb create mode 100644 spec/services/yearly_digests/year_over_year_calculator_spec.rb diff --git a/Gemfile b/Gemfile index 3d1e1649..8e680206 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ gem 'foreman' gem 'geocoder', github: 'Freika/geocoder', branch: 'master' gem 'gpx' gem 'groupdate' +gem 'grover' gem 'h3', '~> 3.7' gem 'httparty' gem 'importmap-rails' diff --git a/Gemfile.lock b/Gemfile.lock index d7203d5e..3de76f0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -204,6 +204,8 @@ GEM rake groupdate (6.7.0) activesupport (>= 7.1) + grover (1.2.4) + nokogiri (~> 1) h3 (3.7.4) ffi (~> 1.9) rgeo-geojson (~> 2.1) @@ -657,6 +659,7 @@ DEPENDENCIES geocoder! gpx groupdate + grover h3 (~> 3.7) httparty importmap-rails diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 1a34fed4..aba7b88a 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -35,7 +35,7 @@ class SettingsController < ApplicationController :meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, - :visits_suggestions_enabled + :visits_suggestions_enabled, :digest_emails_enabled ) end end diff --git a/app/controllers/shared/yearly_digests_controller.rb b/app/controllers/shared/yearly_digests_controller.rb new file mode 100644 index 00000000..3f58d9fc --- /dev/null +++ b/app/controllers/shared/yearly_digests_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Shared::YearlyDigestsController < ApplicationController + helper YearlyDigestsHelper + + before_action :authenticate_user!, except: [:show] + before_action :authenticate_active_user!, only: [:update] + + def show + @digest = YearlyDigest.find_by(sharing_uuid: params[:uuid]) + + unless @digest&.public_accessible? + return redirect_to root_path, + alert: 'Shared digest not found or no longer available' + end + + @year = @digest.year + @user = @digest.user + @distance_unit = @user.safe_settings.distance_unit || 'km' + @is_public_view = true + + render 'yearly_digests/public_year' + end + + def update + @year = params[:year].to_i + @digest = current_user.yearly_digests.yearly.find_by(year: @year) + + return head :not_found unless @digest + + if params[:enabled] == '1' + @digest.enable_sharing!(expiration: params[:expiration] || '24h') + sharing_url = shared_yearly_digest_url(@digest.sharing_uuid) + + render json: { + success: true, + sharing_url: sharing_url, + message: 'Sharing enabled successfully' + } + else + @digest.disable_sharing! + + render json: { + success: true, + message: 'Sharing disabled successfully' + } + end + rescue StandardError + render json: { + success: false, + message: 'Failed to update sharing settings' + }, status: :unprocessable_content + end +end diff --git a/app/controllers/yearly_digests_controller.rb b/app/controllers/yearly_digests_controller.rb new file mode 100644 index 00000000..e0112f81 --- /dev/null +++ b/app/controllers/yearly_digests_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class YearlyDigestsController < ApplicationController + helper YearlyDigestsHelper + + before_action :authenticate_user! + before_action :authenticate_active_user!, only: [:create] + before_action :set_digest, only: [:show] + + def index + @digests = current_user.yearly_digests.yearly.order(year: :desc) + @available_years = available_years_for_generation + end + + def show + @distance_unit = current_user.safe_settings.distance_unit || 'km' + end + + def create + year = params[:year].to_i + + if valid_year?(year) + YearlyDigests::CalculatingJob.perform_later(current_user.id, year) + redirect_to yearly_digests_path, + notice: "Year-end digest for #{year} is being generated. Check back soon!", + status: :see_other + else + redirect_to yearly_digests_path, alert: 'Invalid year selected', status: :see_other + end + end + + private + + def set_digest + @digest = current_user.yearly_digests.yearly.find_by!(year: params[:year]) + rescue ActiveRecord::RecordNotFound + redirect_to yearly_digests_path, alert: 'Digest not found' + end + + def available_years_for_generation + tracked_years = current_user.stats.select(:year).distinct.pluck(:year) + existing_digests = current_user.yearly_digests.yearly.pluck(:year) + + (tracked_years - existing_digests).sort.reverse + end + + def valid_year?(year) + return false if year < 2000 || year > Time.current.year + + current_user.stats.exists?(year: year) + end +end diff --git a/app/helpers/yearly_digests_helper.rb b/app/helpers/yearly_digests_helper.rb new file mode 100644 index 00000000..7c68beb2 --- /dev/null +++ b/app/helpers/yearly_digests_helper.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module YearlyDigestsHelper + EARTH_CIRCUMFERENCE_KM = 40_075 + MOON_DISTANCE_KM = 384_400 + + def distance_with_unit(distance_meters, unit) + value = YearlyDigest.convert_distance(distance_meters, unit).round + "#{number_with_delimiter(value)} #{unit}" + end + + def distance_comparison_text(distance_meters) + distance_km = distance_meters.to_f / 1000 + + if distance_km >= MOON_DISTANCE_KM + percentage = ((distance_km / MOON_DISTANCE_KM) * 100).round(1) + "That's #{percentage}% of the distance to the Moon!" + else + percentage = ((distance_km / EARTH_CIRCUMFERENCE_KM) * 100).round(1) + "That's #{percentage}% of Earth's circumference!" + end + end + + def format_time_spent(minutes) + return "#{minutes} minutes" if minutes < 60 + + hours = minutes / 60 + remaining_minutes = minutes % 60 + + if hours < 24 + "#{hours}h #{remaining_minutes}m" + else + days = hours / 24 + remaining_hours = hours % 24 + "#{days}d #{remaining_hours}h" + end + end + + def yoy_change_class(change) + return '' if change.nil? + + change.negative? ? 'negative' : 'positive' + end + + def yoy_change_text(change) + return '' if change.nil? + + prefix = change.positive? ? '+' : '' + "#{prefix}#{change}%" + end +end diff --git a/app/jobs/yearly_digests/calculating_job.rb b/app/jobs/yearly_digests/calculating_job.rb new file mode 100644 index 00000000..798be33e --- /dev/null +++ b/app/jobs/yearly_digests/calculating_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class YearlyDigests::CalculatingJob < ApplicationJob + queue_as :digests + + def perform(user_id, year) + YearlyDigests::CalculateYear.new(user_id, year).call + rescue StandardError => e + create_digest_failed_notification(user_id, e) + end + + private + + def create_digest_failed_notification(user_id, error) + user = User.find(user_id) + + Notifications::Create.new( + user:, + kind: :error, + title: 'Year-End Digest calculation failed', + content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}" + ).call + rescue ActiveRecord::RecordNotFound + # User was deleted, nothing to notify + end +end diff --git a/app/jobs/yearly_digests/email_sending_job.rb b/app/jobs/yearly_digests/email_sending_job.rb new file mode 100644 index 00000000..1119c4ab --- /dev/null +++ b/app/jobs/yearly_digests/email_sending_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class YearlyDigests::EmailSendingJob < ApplicationJob + queue_as :mailers + + def perform(user_id, year) + user = User.find(user_id) + digest = user.yearly_digests.yearly.find_by(year: year) + + return unless should_send_email?(user, digest) + + YearlyDigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later + + digest.update!(sent_at: Time.current) + rescue ActiveRecord::RecordNotFound + ExceptionReporter.call( + 'YearlyDigests::EmailSendingJob', + "User with ID #{user_id} not found. Skipping year-end digest email." + ) + end + + private + + def should_send_email?(user, digest) + return false unless user.safe_settings.digest_emails_enabled? + return false unless digest.present? + return false if digest.sent_at.present? # Already sent + + true + end +end diff --git a/app/jobs/yearly_digests/year_end_scheduling_job.rb b/app/jobs/yearly_digests/year_end_scheduling_job.rb new file mode 100644 index 00000000..eaeb83ab --- /dev/null +++ b/app/jobs/yearly_digests/year_end_scheduling_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class YearlyDigests::YearEndSchedulingJob < ApplicationJob + queue_as :digests + + def perform + year = Time.current.year - 1 # Previous year's digest + + User.active_or_trial.find_each do |user| + # Skip if user has no data for the year + next unless user.stats.where(year: year).exists? + + # Schedule calculation first + YearlyDigests::CalculatingJob.perform_later(user.id, year) + + # Schedule email with delay to allow calculation to complete + YearlyDigests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year) + end + end +end diff --git a/app/mailers/yearly_digests_mailer.rb b/app/mailers/yearly_digests_mailer.rb new file mode 100644 index 00000000..efcef1a6 --- /dev/null +++ b/app/mailers/yearly_digests_mailer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class YearlyDigestsMailer < ApplicationMailer + helper YearlyDigestsHelper + + def year_end_digest + @user = params[:user] + @digest = params[:digest] + @distance_unit = @user.safe_settings.distance_unit || 'km' + + # Generate chart image + @chart_image_name = generate_chart_attachment + + mail( + to: @user.email, + subject: "Your #{@digest.year} Year in Review - Dawarich" + ) + end + + private + + def generate_chart_attachment + image_data = YearlyDigests::ChartImageGenerator.new(@digest, distance_unit: @distance_unit).call + filename = 'monthly_distance_chart.png' + + attachments.inline[filename] = { + mime_type: 'image/png', + content: image_data + } + + filename + rescue StandardError => e + Rails.logger.error("Failed to generate chart image: #{e.message}") + nil + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 6a591451..87081342 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,6 +21,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy + has_many :yearly_digests, dependent: :destroy after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } diff --git a/app/models/yearly_digest.rb b/app/models/yearly_digest.rb new file mode 100644 index 00000000..ce3c050a --- /dev/null +++ b/app/models/yearly_digest.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +class YearlyDigest < ApplicationRecord + self.table_name = 'digests' + + include DistanceConvertible + + EARTH_CIRCUMFERENCE_KM = 40_075 + MOON_DISTANCE_KM = 384_400 + + belongs_to :user + + validates :year, presence: true + validates :period_type, presence: true + validates :year, uniqueness: { scope: %i[user_id period_type] } + + before_create :generate_sharing_uuid + + enum :period_type, { monthly: 0, yearly: 1 } + + # Sharing methods (following Stat model pattern) + def sharing_enabled? + sharing_settings.try(:[], 'enabled') == true + end + + def sharing_expired? + expiration = sharing_settings.try(:[], 'expiration') + return false if expiration.blank? + + expires_at_value = sharing_settings.try(:[], 'expires_at') + return true if expires_at_value.blank? + + expires_at = begin + Time.zone.parse(expires_at_value) + rescue StandardError + nil + end + + expires_at.present? ? Time.current > expires_at : true + end + + def public_accessible? + sharing_enabled? && !sharing_expired? + end + + def generate_new_sharing_uuid! + update!(sharing_uuid: SecureRandom.uuid) + end + + def enable_sharing!(expiration: '24h') + expiration = '24h' unless %w[1h 12h 24h].include?(expiration) + + expires_at = case expiration + when '1h' then 1.hour.from_now + when '12h' then 12.hours.from_now + when '24h' then 24.hours.from_now + end + + update!( + sharing_settings: { + 'enabled' => true, + 'expiration' => expiration, + 'expires_at' => expires_at.iso8601 + }, + sharing_uuid: sharing_uuid || SecureRandom.uuid + ) + end + + def disable_sharing! + update!( + sharing_settings: { + 'enabled' => false, + 'expiration' => nil, + 'expires_at' => nil + } + ) + end + + # Helper methods for accessing digest data + # toponyms is an array like: [{'country' => 'Germany', 'cities' => [{'city' => 'Berlin'}]}] + def countries_count + return 0 unless toponyms.is_a?(Array) + + toponyms.count { |t| t['country'].present? } + end + + def cities_count + return 0 unless toponyms.is_a?(Array) + + toponyms.sum { |t| t['cities']&.count || 0 } + end + + def first_time_countries + first_time_visits['countries'] || [] + end + + def first_time_cities + first_time_visits['cities'] || [] + end + + def top_countries_by_time + time_spent_by_location['countries'] || [] + end + + def top_cities_by_time + time_spent_by_location['cities'] || [] + end + + def yoy_distance_change + year_over_year['distance_change_percent'] + end + + def yoy_countries_change + year_over_year['countries_change'] + end + + def yoy_cities_change + year_over_year['cities_change'] + end + + def previous_year + year_over_year['previous_year'] + end + + def total_countries_all_time + all_time_stats['total_countries'] || 0 + end + + def total_cities_all_time + all_time_stats['total_cities'] || 0 + end + + def total_distance_all_time + all_time_stats['total_distance'] || 0 + end + + def distance_km + distance.to_f / 1000 + end + + def distance_comparison_text + if distance_km >= MOON_DISTANCE_KM + percentage = ((distance_km / MOON_DISTANCE_KM) * 100).round(1) + "That's #{percentage}% of the distance to the Moon!" + else + percentage = ((distance_km / EARTH_CIRCUMFERENCE_KM) * 100).round(1) + "That's #{percentage}% of Earth's circumference!" + end + end + + private + + def generate_sharing_uuid + self.sharing_uuid ||= SecureRandom.uuid + end +end diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb index 3e01f73c..909854b4 100644 --- a/app/services/users/safe_settings.rb +++ b/app/services/users/safe_settings.rb @@ -21,7 +21,8 @@ class Users::SafeSettings 'maps' => { 'distance_unit' => 'km' }, 'visits_suggestions_enabled' => 'true', 'enabled_map_layers' => ['Routes', 'Heatmap'], - 'maps_maplibre_style' => 'light' + 'maps_maplibre_style' => 'light', + 'digest_emails_enabled' => true }.freeze def initialize(settings = {}) @@ -139,4 +140,8 @@ class Users::SafeSettings def maps_maplibre_style settings['maps_maplibre_style'] end + + def digest_emails_enabled? + settings['digest_emails_enabled'] != false + end end diff --git a/app/services/yearly_digests/calculate_year.rb b/app/services/yearly_digests/calculate_year.rb new file mode 100644 index 00000000..3910dee5 --- /dev/null +++ b/app/services/yearly_digests/calculate_year.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module YearlyDigests + class CalculateYear + def initialize(user_id, year) + @user = User.find(user_id) + @year = year.to_i + end + + def call + return nil if monthly_stats.empty? + + digest = YearlyDigest.find_or_initialize_by(user: user, year: year, period_type: :yearly) + + digest.assign_attributes( + distance: total_distance, + toponyms: aggregate_toponyms, + monthly_distances: build_monthly_distances, + time_spent_by_location: calculate_time_spent, + first_time_visits: calculate_first_time_visits, + year_over_year: calculate_yoy_comparison, + all_time_stats: calculate_all_time_stats + ) + + digest.save! + digest + end + + private + + attr_reader :user, :year + + def monthly_stats + @monthly_stats ||= user.stats.where(year: year).order(:month) + end + + def total_distance + monthly_stats.sum(:distance) + end + + def aggregate_toponyms + countries = [] + cities = [] + + monthly_stats.each do |stat| + toponyms = stat.toponyms + next unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + next unless toponym.is_a?(Hash) + + countries << toponym['country'] if toponym['country'].present? + + next unless toponym['cities'].is_a?(Array) + + toponym['cities'].each do |city| + cities << city['city'] if city.is_a?(Hash) && city['city'].present? + end + end + end + + { + 'countries' => countries.uniq.compact.sort, + 'cities' => cities.uniq.compact.sort + } + end + + def build_monthly_distances + result = {} + + monthly_stats.each do |stat| + result[stat.month.to_s] = stat.distance + end + + # Fill in missing months with 0 + (1..12).each do |month| + result[month.to_s] ||= 0 + end + + result + end + + def calculate_time_spent + country_time = Hash.new(0) + city_time = Hash.new(0) + + monthly_stats.each do |stat| + toponyms = stat.toponyms + next unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + next unless toponym.is_a?(Hash) + + country = toponym['country'] + next unless toponym['cities'].is_a?(Array) + + toponym['cities'].each do |city| + next unless city.is_a?(Hash) + + stayed_for = city['stayed_for'].to_i + city_name = city['city'] + + country_time[country] += stayed_for if country.present? + city_time[city_name] += stayed_for if city_name.present? + end + end + end + + { + 'countries' => country_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } }, + 'cities' => city_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } } + } + end + + def calculate_first_time_visits + FirstTimeVisitsCalculator.new(user, year).call + end + + def calculate_yoy_comparison + YearOverYearCalculator.new(user, year).call + end + + def calculate_all_time_stats + { + 'total_countries' => user.countries_visited.count, + 'total_cities' => user.cities_visited.count, + 'total_distance' => user.stats.sum(:distance) + } + end + end +end diff --git a/app/services/yearly_digests/chart_image_generator.rb b/app/services/yearly_digests/chart_image_generator.rb new file mode 100644 index 00000000..6ae28186 --- /dev/null +++ b/app/services/yearly_digests/chart_image_generator.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module YearlyDigests + class ChartImageGenerator + def initialize(digest, distance_unit: 'km') + @digest = digest + @distance_unit = distance_unit + end + + def call + html = render_chart_html + generate_image(html) + end + + private + + attr_reader :digest, :distance_unit + + def render_chart_html + ApplicationController.render( + template: 'yearly_digests/chart', + layout: false, + assigns: { + monthly_distances: digest.monthly_distances, + distance_unit: distance_unit + } + ) + end + + def generate_image(html) + grover = Grover.new( + html, + format: 'png', + viewport: { width: 600, height: 320 }, + wait_until: 'networkidle0' + ) + grover.to_png + end + end +end diff --git a/app/services/yearly_digests/first_time_visits_calculator.rb b/app/services/yearly_digests/first_time_visits_calculator.rb new file mode 100644 index 00000000..b8a22f90 --- /dev/null +++ b/app/services/yearly_digests/first_time_visits_calculator.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module YearlyDigests + class FirstTimeVisitsCalculator + def initialize(user, year) + @user = user + @year = year.to_i + end + + def call + { + 'countries' => first_time_countries, + 'cities' => first_time_cities + } + end + + private + + attr_reader :user, :year + + def previous_years_stats + @previous_years_stats ||= user.stats.where('year < ?', year) + end + + def current_year_stats + @current_year_stats ||= user.stats.where(year: year) + end + + def previous_countries + @previous_countries ||= extract_countries(previous_years_stats) + end + + def previous_cities + @previous_cities ||= extract_cities(previous_years_stats) + end + + def current_countries + @current_countries ||= extract_countries(current_year_stats) + end + + def current_cities + @current_cities ||= extract_cities(current_year_stats) + end + + def first_time_countries + (current_countries - previous_countries).sort + end + + def first_time_cities + (current_cities - previous_cities).sort + end + + def extract_countries(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) } + end.uniq.compact + end + + def extract_cities(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.flat_map do |t| + next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array) + + t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) } + end + end.uniq.compact + end + end +end diff --git a/app/services/yearly_digests/year_over_year_calculator.rb b/app/services/yearly_digests/year_over_year_calculator.rb new file mode 100644 index 00000000..b583dfcf --- /dev/null +++ b/app/services/yearly_digests/year_over_year_calculator.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module YearlyDigests + class YearOverYearCalculator + def initialize(user, year) + @user = user + @year = year.to_i + end + + def call + return {} unless previous_year_stats.exists? + + { + 'previous_year' => year - 1, + 'distance_change_percent' => calculate_distance_change_percent, + 'countries_change' => calculate_countries_change, + 'cities_change' => calculate_cities_change + }.compact + end + + private + + attr_reader :user, :year + + def previous_year_stats + @previous_year_stats ||= user.stats.where(year: year - 1) + end + + def current_year_stats + @current_year_stats ||= user.stats.where(year: year) + end + + def calculate_distance_change_percent + prev_distance = previous_year_stats.sum(:distance) + return nil if prev_distance.zero? + + curr_distance = current_year_stats.sum(:distance) + ((curr_distance - prev_distance).to_f / prev_distance * 100).round + end + + def calculate_countries_change + prev_count = count_countries(previous_year_stats) + curr_count = count_countries(current_year_stats) + + curr_count - prev_count + end + + def calculate_cities_change + prev_count = count_cities(previous_year_stats) + curr_count = count_cities(current_year_stats) + + curr_count - prev_count + end + + def count_countries(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) } + end.uniq.compact.count + end + + def count_cities(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.flat_map do |t| + next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array) + + t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) } + end + end.uniq.compact.count + end + end +end diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 94e70556..4c4bcdf2 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -69,6 +69,27 @@ +
+

+ <%= icon 'mail', class: "text-primary mr-2" %> Email Preferences +

+
+
+ +
+
+
+ <% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>

diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 2e9b40eb..85e794f3 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -1,6 +1,13 @@ <% content_for :title, 'Statistics' %>
+
+

Statistics

+ <%= link_to yearly_digests_path, class: 'btn btn-outline btn-sm' do %> + <%= icon 'earth' %> Year-End Digests + <% end %> +
+
diff --git a/app/views/yearly_digests/chart.html.erb b/app/views/yearly_digests/chart.html.erb new file mode 100644 index 00000000..14450be6 --- /dev/null +++ b/app/views/yearly_digests/chart.html.erb @@ -0,0 +1,75 @@ + + + + + + + + +
+ +
+ + + diff --git a/app/views/yearly_digests/index.html.erb b/app/views/yearly_digests/index.html.erb new file mode 100644 index 00000000..a012b9db --- /dev/null +++ b/app/views/yearly_digests/index.html.erb @@ -0,0 +1,91 @@ +<% content_for :title, 'Year-End Digests' %> + +
+
+

+ <%= icon 'earth' %> Year-End Digests +

+ + <% if @available_years.any? && current_user.active? %> + + <% end %> +
+ + <% if @digests.empty? %> +
+
+
<%= icon 'earth' %>
+

No Year-End Digests Yet

+

+ Year-end digests are automatically generated on January 1st each year. + <% if @available_years.any? && current_user.active? %> +
Or you can manually generate one for a previous year. + <% end %> +

+
+
+ <% else %> +
+ <% @digests.each do |digest| %> +
+
+

+ <%= link_to digest.year, yearly_digest_path(year: digest.year), class: 'hover:text-primary' %> + <% if digest.sharing_enabled? %> + Shared + <% end %> +

+ +
+
+
Distance
+
+ <%= distance_with_unit(digest.distance, current_user.safe_settings.distance_unit) %> +
+
+ +
+
Countries
+
<%= digest.countries_count %>
+ <% if digest.first_time_countries.any? %> +
+ <%= icon 'star' %> <%= digest.first_time_countries.count %> new +
+ <% end %> +
+ +
+
Cities
+
<%= digest.cities_count %>
+ <% if digest.first_time_cities.any? %> +
+ <%= icon 'star' %> <%= digest.first_time_cities.count %> new +
+ <% end %> +
+
+ +
+ <%= link_to yearly_digest_path(year: digest.year), class: 'btn btn-primary btn-sm' do %> + View Details + <% end %> +
+
+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/yearly_digests/public_year.html.erb b/app/views/yearly_digests/public_year.html.erb new file mode 100644 index 00000000..59c1a6e6 --- /dev/null +++ b/app/views/yearly_digests/public_year.html.erb @@ -0,0 +1,183 @@ +
+ +
+
+
+

<%= @digest.year %> Year in Review

+

A journey, by the numbers

+
+
+
+ + +
+
+
Distance traveled
+
<%= distance_with_unit(@digest.distance, @distance_unit) %>
+
<%= distance_comparison_text(@digest.distance) %>
+
+ +
+
Countries visited
+
<%= @digest.countries_count %>
+ <% if @digest.first_time_countries.any? %> +
<%= @digest.first_time_countries.count %> first time
+ <% end %> +
+ +
+
Cities explored
+
<%= @digest.cities_count %>
+ <% if @digest.first_time_cities.any? %> +
<%= @digest.first_time_cities.count %> first time
+ <% end %> +
+
+ + + <% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %> +
+
+

+ <%= icon 'star' %> First Time Visits +

+ + <% if @digest.first_time_countries.any? %> +
+

New Countries

+
+ <% @digest.first_time_countries.each do |country| %> + <%= country %> + <% end %> +
+
+ <% end %> + + <% if @digest.first_time_cities.any? %> +
+

New Cities

+
+ <% @digest.first_time_cities.take(5).each do |city| %> + <%= city %> + <% end %> + <% if @digest.first_time_cities.count > 5 %> + +<%= @digest.first_time_cities.count - 5 %> more + <% end %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @digest.monthly_distances.present? %> +
+
+

+ <%= icon 'activity' %> Year by Month +

+
+ <%= column_chart( + @digest.monthly_distances.sort.map { |month, distance_meters| + [Date::ABBR_MONTHNAMES[month.to_i], YearlyDigest.convert_distance(distance_meters, @distance_unit).round] + }, + height: '200px', + suffix: " #{@distance_unit}", + xtitle: 'Month', + ytitle: 'Distance', + colors: [ + '#397bb5', '#5A4E9D', '#3B945E', + '#7BC96F', '#FFD54F', '#FFA94D', + '#FF6B6B', '#FF8C42', '#C97E4F', + '#8B4513', '#5A2E2E', '#265d7d' + ] + ) %> +
+
+
+ <% end %> + + + <% if @digest.top_countries_by_time.any? %> +
+
+

+ <%= icon 'map-pin' %> Where They Spent the Most Time +

+
    + <% @digest.top_countries_by_time.take(3).each do |country| %> +
  • + <%= country['name'] %> + <%= format_time_spent(country['minutes']) %> +
  • + <% end %> +
+
+
+ <% end %> + + +
+
+

+ <%= icon 'earth' %> Countries & Cities +

+
+ <% @digest.toponyms&.each_with_index do |country, index| %> +
+
+ <%= country['country'] %> + <%= country['cities']&.length || 0 %> cities +
+ +
+ <% end %> +
+ +
+ +
+ Cities visited: + <% @digest.toponyms&.each do |country| %> + <% country['cities']&.take(5)&.each do |city| %> +
<%= city['city'] %>
+ <% end %> + <% if country['cities']&.length.to_i > 5 %> +
+<%= country['cities'].length - 5 %> more
+ <% end %> + <% end %> +
+
+
+ + +
+
+

+ <%= icon 'trophy' %> All-Time Stats +

+
+
+
Countries visited
+
<%= @digest.total_countries_all_time %>
+
+
+
Cities explored
+
<%= @digest.total_cities_all_time %>
+
+
+
Total distance
+
<%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %>
+
+
+
+
+ + +
+
+ Powered by Dawarich, your personal memories mapper. +
+
+
diff --git a/app/views/yearly_digests/show.html.erb b/app/views/yearly_digests/show.html.erb new file mode 100644 index 00000000..2f786f86 --- /dev/null +++ b/app/views/yearly_digests/show.html.erb @@ -0,0 +1,313 @@ +<% content_for :title, "#{@digest.year} Year in Review" %> + +
+ +
+
+
+

<%= @digest.year %> Year in Review

+

Your journey, by the numbers

+ +
+
+
+ + +
+
+
+ <%= icon 'map' %> Distance Traveled +
+
+ <%= distance_with_unit(@digest.distance, @distance_unit) %> +
+

<%= distance_comparison_text(@digest.distance) %>

+ <% if @digest.yoy_distance_change %> +

+ <%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %> +

+ <% end %> +
+
+ + +
+
+
+ <%= icon 'globe' %> Countries +
+
<%= @digest.countries_count %>
+ <% if @digest.first_time_countries.any? %> +
+ <%= icon 'star' %> <%= @digest.first_time_countries.count %> first time +
+ <% end %> +
+ +
+
+ <%= icon 'building' %> Cities +
+
<%= @digest.cities_count %>
+ <% if @digest.first_time_cities.any? %> +
+ <%= icon 'star' %> <%= @digest.first_time_cities.count %> first time +
+ <% end %> +
+
+ + + <% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %> +
+
+

+ <%= icon 'star' %> First Time Visits +

+ + <% if @digest.first_time_countries.any? %> +
+

New Countries

+
+ <% @digest.first_time_countries.each do |country| %> + <%= country %> + <% end %> +
+
+ <% end %> + + <% if @digest.first_time_cities.any? %> +
+

New Cities

+
+ <% @digest.first_time_cities.take(10).each do |city| %> + <%= city %> + <% end %> + <% if @digest.first_time_cities.count > 10 %> + +<%= @digest.first_time_cities.count - 10 %> more + <% end %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @digest.monthly_distances.present? %> +
+
+

+ <%= icon 'activity' %> Your Year, Month by Month +

+
+ <%= column_chart( + @digest.monthly_distances.sort.map { |month, distance_meters| + [Date::ABBR_MONTHNAMES[month.to_i], YearlyDigest.convert_distance(distance_meters, @distance_unit).round] + }, + height: '250px', + suffix: " #{@distance_unit}", + xtitle: 'Month', + ytitle: 'Distance', + colors: [ + '#397bb5', '#5A4E9D', '#3B945E', + '#7BC96F', '#FFD54F', '#FFA94D', + '#FF6B6B', '#FF8C42', '#C97E4F', + '#8B4513', '#5A2E2E', '#265d7d' + ] + ) %> +
+
+
+ <% end %> + + + <% if @digest.top_countries_by_time.any? %> +
+
+

+ <%= icon 'map-pin' %> Where You Spent the Most Time +

+
+ <% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %> +
+
+ + <%= index + 1 %> + + <%= country['name'] %> +
+ <%= format_time_spent(country['minutes']) %> +
+ <% end %> +
+
+
+ <% end %> + + +
+
+

+ <%= icon 'earth' %> Countries & Cities +

+
+ <% if @digest.toponyms.present? %> + <% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %> + <% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %> + + <% @digest.toponyms.each_with_index do |country, index| %> + <% cities_count = country['cities']&.length || 0 %> + <% progress_value = max_cities&.positive? ? (cities_count.to_f / max_cities * 100).round : 0 %> + <% color_class = progress_colors[index % progress_colors.length] %> + +
+
+ <%= country['country'] %> + + <%= pluralize(cities_count, 'city') %> + +
+ +
+ <% end %> + <% else %> +

No location data available

+ <% end %> +
+
+
+ + +
+
+

+ <%= icon 'trophy' %> All-Time Stats +

+
+
+
Countries visited
+
<%= @digest.total_countries_all_time %>
+
+
+
Cities explored
+
<%= @digest.total_cities_all_time %>
+
+
+
Total distance
+
<%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %>
+
+
+
+
+ + +
+ <%= link_to yearly_digests_path, class: 'btn btn-outline' do %> + Back to All Digests + <% end %> + +
+
+ + + + + diff --git a/app/views/yearly_digests_mailer/year_end_digest.html.erb b/app/views/yearly_digests_mailer/year_end_digest.html.erb new file mode 100644 index 00000000..05a8183d --- /dev/null +++ b/app/views/yearly_digests_mailer/year_end_digest.html.erb @@ -0,0 +1,254 @@ + + + + + + + + +
+

<%= @digest.year %> Year in Review

+

Your journey, by the numbers

+
+ +
+ +
+
Distance Traveled
+

<%= distance_with_unit(@digest.distance, @distance_unit) %>

+

<%= distance_comparison_text(@digest.distance) %>

+ <% if @digest.yoy_distance_change %> +

+ <%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %> +

+ <% end %> +
+ + +
+
Countries Visited
+

<%= @digest.countries_count %>

+ <% if @digest.first_time_countries.any? %> +

+ New + First time in: <%= @digest.first_time_countries.join(', ') %> +

+ <% end %> +
+ + +
+
Cities Explored
+

<%= @digest.cities_count %>

+ <% if @digest.first_time_cities.any? %> +

+ New + <% cities_to_show = @digest.first_time_cities.take(5) %> + First time in: <%= cities_to_show.join(', ') %> + <% if @digest.first_time_cities.count > 5 %> + and <%= @digest.first_time_cities.count - 5 %> more + <% end %> +

+ <% end %> +
+ + + <% if @chart_image_name %> +
+

Your Year, Month by Month

+ <%= image_tag attachments[@chart_image_name].url, alt: 'Monthly Distance Chart' %> +
+ <% end %> + + + <% if @digest.top_countries_by_time.any? %> +
+
Where You Spent the Most Time
+
    + <% @digest.top_countries_by_time.take(3).each do |country| %> +
  • + <%= country['name'] %> + <%= format_time_spent(country['minutes']) %> +
  • + <% end %> +
+
+ <% end %> + + + +
+ + + + diff --git a/app/views/yearly_digests_mailer/year_end_digest.text.erb b/app/views/yearly_digests_mailer/year_end_digest.text.erb new file mode 100644 index 00000000..b5a4e05a --- /dev/null +++ b/app/views/yearly_digests_mailer/year_end_digest.text.erb @@ -0,0 +1,41 @@ +<%= @digest.year %> Year in Review +==================================== + +Hi <%= @user.email %>, + +Here's your year in review! + +DISTANCE TRAVELED +<%= distance_with_unit(@digest.distance, @distance_unit) %> +<%= distance_comparison_text(@digest.distance) %> +<% if @digest.yoy_distance_change %> +<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %> +<% end %> + +COUNTRIES VISITED: <%= @digest.countries_count %> +<% if @digest.first_time_countries.any? %> +First time in: <%= @digest.first_time_countries.join(', ') %> +<% end %> + +CITIES EXPLORED: <%= @digest.cities_count %> +<% if @digest.first_time_cities.any? %> +First time in: <%= @digest.first_time_cities.take(5).join(', ') %><% if @digest.first_time_cities.count > 5 %> and <%= @digest.first_time_cities.count - 5 %> more<% end %> +<% end %> + +<% if @digest.top_countries_by_time.any? %> +WHERE YOU SPENT THE MOST TIME +<% @digest.top_countries_by_time.take(3).each do |country| %> +- <%= country['name'] %>: <%= format_time_spent(country['minutes']) %> +<% end %> +<% end %> + +ALL-TIME STATS +- <%= @digest.total_countries_all_time %> countries visited +- <%= @digest.total_cities_all_time %> cities explored +- <%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %> traveled + +-- +Powered by Dawarich +https://dawarich.app + +Manage your email preferences: <%= settings_url(host: ENV.fetch('DOMAIN', 'localhost')) %> diff --git a/config/initializers/grover.rb b/config/initializers/grover.rb new file mode 100644 index 00000000..61840157 --- /dev/null +++ b/config/initializers/grover.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Grover.configure do |config| + config.options = { + format: 'png', + quality: 90, + wait_until: 'networkidle0', + launch_args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] + } +end diff --git a/config/routes.rb b/config/routes.rb index 8ee7565d..4f4ed700 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -98,6 +98,15 @@ Rails.application.routes.draw do as: :sharing_stats, constraints: { year: /\d{4}/, month: /\d{1,2}/ } + # Yearly digests routes + resources :yearly_digests, only: %i[index create], param: :year + get 'yearly_digests/:year', to: 'yearly_digests#show', as: :yearly_digest, constraints: { year: /\d{4}/ } + get 'shared/year/:uuid', to: 'shared/yearly_digests#show', as: :shared_yearly_digest + patch 'yearly_digests/:year/sharing', + to: 'shared/yearly_digests#update', + as: :sharing_yearly_digest, + constraints: { year: /\d{4}/ } + root to: 'home#index' get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success diff --git a/config/schedule.yml b/config/schedule.yml index ae920927..8beea909 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -49,3 +49,8 @@ nightly_family_invitations_cleanup_job: cron: "30 2 * * *" # every day at 02:30 class: "Family::Invitations::CleanupJob" queue: family + +year_end_digest_job: + cron: "0 0 1 1 *" # January 1st at 00:00 + class: "YearlyDigests::YearEndSchedulingJob" + queue: digests diff --git a/config/sidekiq.yml b/config/sidekiq.yml index a4464488..e7215709 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -17,3 +17,4 @@ - app_version_checking - cache - archival + - digests diff --git a/db/migrate/20251227000001_create_digests.rb b/db/migrate/20251227000001_create_digests.rb new file mode 100644 index 00000000..227a2fb8 --- /dev/null +++ b/db/migrate/20251227000001_create_digests.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CreateDigests < ActiveRecord::Migration[8.0] + def change + create_table :digests do |t| + t.references :user, null: false, foreign_key: true + t.integer :year, null: false + t.integer :period_type, null: false, default: 0 # enum: monthly: 0, yearly: 1 + + # Aggregated data + t.integer :distance, null: false, default: 0 # Total distance in meters + t.jsonb :toponyms, default: {} # Countries/cities data + t.jsonb :monthly_distances, default: {} # {1: meters, 2: meters, ...} + t.jsonb :time_spent_by_location, default: {} # Top locations by time + + # First-time visits (calculated from historical data) + t.jsonb :first_time_visits, default: {} # {countries: [], cities: []} + + # Comparisons + t.jsonb :year_over_year, default: {} # {distance_change_percent: 15, ...} + t.jsonb :all_time_stats, default: {} # {total_countries: 50, ...} + + # Sharing (like Stat model) + t.jsonb :sharing_settings, default: {} + t.uuid :sharing_uuid + + # Email tracking + t.datetime :sent_at + + t.timestamps + end + + add_index :digests, %i[user_id year period_type], unique: true + add_index :digests, :sharing_uuid, unique: true + add_index :digests, :year + add_index :digests, :period_type + end +end diff --git a/db/schema.rb b/db/schema.rb index 089b01c7..c6779875 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_12_26_170919) do +ActiveRecord::Schema[8.0].define(version: 2025_12_27_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -80,6 +80,29 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_26_170919) do create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t| end + create_table "digests", force: :cascade do |t| + t.bigint "user_id", null: false + t.integer "year", null: false + t.integer "period_type", default: 0, null: false + t.integer "distance", default: 0, null: false + t.jsonb "toponyms", default: {} + t.jsonb "monthly_distances", default: {} + t.jsonb "time_spent_by_location", default: {} + t.jsonb "first_time_visits", default: {} + t.jsonb "year_over_year", default: {} + t.jsonb "all_time_stats", default: {} + t.jsonb "sharing_settings", default: {} + t.uuid "sharing_uuid" + t.datetime "sent_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["period_type"], name: "index_digests_on_period_type" + t.index ["sharing_uuid"], name: "index_digests_on_sharing_uuid", unique: true + t.index ["user_id", "year", "period_type"], name: "index_digests_on_user_id_and_year_and_period_type", unique: true + t.index ["user_id"], name: "index_digests_on_user_id" + t.index ["year"], name: "index_digests_on_year" + end + create_table "exports", force: :cascade do |t| t.string "name", null: false t.string "url" @@ -400,6 +423,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_26_170919) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "areas", "users" + add_foreign_key "digests", "users" add_foreign_key "families", "users", column: "creator_id" add_foreign_key "family_invitations", "families" add_foreign_key "family_invitations", "users", column: "invited_by_id" diff --git a/spec/factories/yearly_digests.rb b/spec/factories/yearly_digests.rb new file mode 100644 index 00000000..cfcf819f --- /dev/null +++ b/spec/factories/yearly_digests.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :yearly_digest do + year { 2024 } + period_type { :yearly } + distance { 500_000 } # 500 km + user + sharing_settings { {} } + sharing_uuid { SecureRandom.uuid } + + toponyms do + [ + { + 'country' => 'Germany', + 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] + }, + { + 'country' => 'France', + 'cities' => [{ 'city' => 'Paris' }] + }, + { + 'country' => 'Spain', + 'cities' => [{ 'city' => 'Madrid' }, { 'city' => 'Barcelona' }] + } + ] + end + + monthly_distances do + { + '1' => 50_000, + '2' => 45_000, + '3' => 60_000, + '4' => 55_000, + '5' => 40_000, + '6' => 35_000, + '7' => 30_000, + '8' => 45_000, + '9' => 50_000, + '10' => 40_000, + '11' => 25_000, + '12' => 25_000 + } + end + + time_spent_by_location do + { + 'countries' => [ + { 'name' => 'Germany', 'minutes' => 10_080 }, + { 'name' => 'France', 'minutes' => 4_320 }, + { 'name' => 'Spain', 'minutes' => 2_880 } + ], + 'cities' => [ + { 'name' => 'Berlin', 'minutes' => 5_040 }, + { 'name' => 'Paris', 'minutes' => 4_320 }, + { 'name' => 'Madrid', 'minutes' => 1_440 } + ] + } + end + + first_time_visits do + { + 'countries' => ['Spain'], + 'cities' => %w[Madrid Barcelona] + } + end + + year_over_year do + { + 'previous_year' => 2023, + 'distance_change_percent' => 15, + 'countries_change' => 1, + 'cities_change' => 2 + } + end + + all_time_stats do + { + 'total_countries' => 10, + 'total_cities' => 45, + 'total_distance' => 2_500_000 + } + end + + trait :with_sharing_enabled do + after(:create) do |digest, _evaluator| + digest.enable_sharing!(expiration: '24h') + end + end + + trait :with_sharing_disabled do + sharing_settings do + { + 'enabled' => false, + 'expiration' => nil, + 'expires_at' => nil + } + end + end + + trait :with_sharing_expired do + sharing_settings do + { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + } + end + end + + trait :sent do + sent_at { 1.day.ago } + end + + trait :monthly do + period_type { :monthly } + end + + trait :without_previous_year do + year_over_year { {} } + end + + trait :first_year do + first_time_visits do + { + 'countries' => %w[Germany France Spain], + 'cities' => ['Berlin', 'Paris', 'Madrid', 'Barcelona'] + } + end + year_over_year { {} } + end + end +end diff --git a/spec/jobs/yearly_digests/calculating_job_spec.rb b/spec/jobs/yearly_digests/calculating_job_spec.rb new file mode 100644 index 00000000..c8037226 --- /dev/null +++ b/spec/jobs/yearly_digests/calculating_job_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigests::CalculatingJob, type: :job do + describe '#perform' do + let!(:user) { create(:user) } + let(:year) { 2024 } + + subject { described_class.perform_now(user.id, year) } + + before do + allow(YearlyDigests::CalculateYear).to receive(:new).and_call_original + allow_any_instance_of(YearlyDigests::CalculateYear).to receive(:call) + end + + it 'calls YearlyDigests::CalculateYear service' do + subject + + expect(YearlyDigests::CalculateYear).to have_received(:new).with(user.id, year) + end + + it 'enqueues to the digests queue' do + expect(described_class.new.queue_name).to eq('digests') + end + + context 'when YearlyDigests::CalculateYear raises an error' do + before do + allow_any_instance_of(YearlyDigests::CalculateYear).to receive(:call).and_raise(StandardError.new('Test error')) + end + + it 'creates an error notification' do + expect { subject }.to change { Notification.count }.by(1) + expect(Notification.last.kind).to eq('error') + expect(Notification.last.title).to include('Year-End Digest') + end + end + + context 'when user does not exist' do + before do + allow_any_instance_of(YearlyDigests::CalculateYear).to receive(:call).and_raise(ActiveRecord::RecordNotFound) + end + + it 'does not raise error' do + expect { subject }.not_to raise_error + end + end + end +end diff --git a/spec/jobs/yearly_digests/email_sending_job_spec.rb b/spec/jobs/yearly_digests/email_sending_job_spec.rb new file mode 100644 index 00000000..329f4d4d --- /dev/null +++ b/spec/jobs/yearly_digests/email_sending_job_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigests::EmailSendingJob, type: :job do + describe '#perform' do + let!(:user) { create(:user) } + let(:year) { 2024 } + let!(:digest) { create(:yearly_digest, user: user, year: year, period_type: :yearly) } + + subject { described_class.perform_now(user.id, year) } + + before do + # Mock the mailer + allow(YearlyDigestsMailer).to receive_message_chain(:with, :year_end_digest, :deliver_later) + end + + it 'enqueues to the mailers queue' do + expect(described_class.new.queue_name).to eq('mailers') + end + + context 'when user has digest emails enabled' do + it 'sends the email' do + subject + + expect(YearlyDigestsMailer).to have_received(:with).with(user: user, digest: digest) + end + + it 'updates the sent_at timestamp' do + expect { subject }.to change { digest.reload.sent_at }.from(nil) + end + end + + context 'when user has digest emails disabled' do + before do + user.update!(settings: user.settings.merge('digest_emails_enabled' => false)) + end + + it 'does not send the email' do + subject + + expect(YearlyDigestsMailer).not_to have_received(:with) + end + end + + context 'when digest does not exist' do + before { digest.destroy } + + it 'does not send the email' do + subject + + expect(YearlyDigestsMailer).not_to have_received(:with) + end + end + + context 'when digest was already sent' do + before { digest.update!(sent_at: 1.day.ago) } + + it 'does not send the email again' do + subject + + expect(YearlyDigestsMailer).not_to have_received(:with) + end + end + + context 'when user does not exist' do + before { user.destroy } + + it 'does not raise error' do + expect { described_class.perform_now(999_999, year) }.not_to raise_error + end + + it 'reports the exception' do + expect(ExceptionReporter).to receive(:call).with( + 'YearlyDigests::EmailSendingJob', + anything + ) + + described_class.perform_now(999_999, year) + end + end + end +end diff --git a/spec/jobs/yearly_digests/year_end_scheduling_job_spec.rb b/spec/jobs/yearly_digests/year_end_scheduling_job_spec.rb new file mode 100644 index 00000000..8f72e6f0 --- /dev/null +++ b/spec/jobs/yearly_digests/year_end_scheduling_job_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do + describe '#perform' do + subject { described_class.perform_now } + + let(:previous_year) { Time.current.year - 1 } + + it 'enqueues to the digests queue' do + expect(described_class.new.queue_name).to eq('digests') + end + + context 'with users having different statuses' do + let!(:active_user) { create(:user, status: :active) } + let!(:trial_user) { create(:user, status: :trial) } + let!(:inactive_user) { create(:user) } + + before do + # Force inactive status after any after_commit callbacks + inactive_user.update_column(:status, 0) # inactive + + create(:stat, user: active_user, year: previous_year, month: 1) + create(:stat, user: trial_user, year: previous_year, month: 1) + create(:stat, user: inactive_user, year: previous_year, month: 1) + + allow(YearlyDigests::CalculatingJob).to receive(:perform_later) + allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) + end + + it 'schedules jobs for active users' do + subject + + expect(YearlyDigests::CalculatingJob).to have_received(:perform_later) + .with(active_user.id, previous_year) + end + + it 'schedules jobs for trial users' do + subject + + expect(YearlyDigests::CalculatingJob).to have_received(:perform_later) + .with(trial_user.id, previous_year) + end + + it 'does not schedule jobs for inactive users' do + subject + + expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later) + .with(inactive_user.id, anything) + end + + it 'schedules email sending job with delay' do + email_job_double = double(perform_later: nil) + allow(YearlyDigests::EmailSendingJob).to receive(:set) + .with(wait: 30.minutes) + .and_return(email_job_double) + + subject + + expect(YearlyDigests::EmailSendingJob).to have_received(:set) + .with(wait: 30.minutes).at_least(:twice) + end + end + + context 'when user has no stats for previous year' do + let!(:user_without_stats) { create(:user, status: :active) } + let!(:user_with_stats) { create(:user, status: :active) } + + before do + create(:stat, user: user_with_stats, year: previous_year, month: 1) + + allow(YearlyDigests::CalculatingJob).to receive(:perform_later) + allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) + end + + it 'does not schedule jobs for user without stats' do + subject + + expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later) + .with(user_without_stats.id, anything) + end + + it 'schedules jobs for user with stats' do + subject + + expect(YearlyDigests::CalculatingJob).to have_received(:perform_later) + .with(user_with_stats.id, previous_year) + end + end + + context 'when user only has stats for current year' do + let!(:user_current_year_only) { create(:user, status: :active) } + + before do + create(:stat, user: user_current_year_only, year: Time.current.year, month: 1) + + allow(YearlyDigests::CalculatingJob).to receive(:perform_later) + allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) + end + + it 'does not schedule jobs for that user' do + subject + + expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later) + .with(user_current_year_only.id, anything) + end + end + end +end diff --git a/spec/models/yearly_digest_spec.rb b/spec/models/yearly_digest_spec.rb new file mode 100644 index 00000000..841a1ebd --- /dev/null +++ b/spec/models/yearly_digest_spec.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigest, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:year) } + it { is_expected.to validate_presence_of(:period_type) } + + describe 'uniqueness of year within scope' do + let(:user) { create(:user) } + let!(:existing_digest) { create(:yearly_digest, user: user, year: 2024, period_type: :yearly) } + + it 'does not allow duplicate yearly digest for same user and year' do + duplicate = build(:yearly_digest, user: user, year: 2024, period_type: :yearly) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:year]).to include('has already been taken') + end + + it 'allows same year for different period types' do + monthly = build(:yearly_digest, user: user, year: 2024, period_type: :monthly) + expect(monthly).to be_valid + end + + it 'allows same year for different users' do + other_user = create(:user) + other_digest = build(:yearly_digest, user: other_user, year: 2024, period_type: :yearly) + expect(other_digest).to be_valid + end + end + end + + describe 'enums' do + it { is_expected.to define_enum_for(:period_type).with_values(monthly: 0, yearly: 1) } + end + + describe 'callbacks' do + describe 'before_create :generate_sharing_uuid' do + it 'generates a sharing_uuid if not present' do + digest = build(:yearly_digest, sharing_uuid: nil) + digest.save! + expect(digest.sharing_uuid).to be_present + end + + it 'does not overwrite existing sharing_uuid' do + existing_uuid = SecureRandom.uuid + digest = build(:yearly_digest, sharing_uuid: existing_uuid) + digest.save! + expect(digest.sharing_uuid).to eq(existing_uuid) + end + end + end + + describe 'helper methods' do + let(:user) { create(:user) } + let(:digest) { create(:yearly_digest, user: user) } + + describe '#countries_count' do + it 'returns count of countries from toponyms' do + expect(digest.countries_count).to eq(3) + end + + context 'when toponyms countries is nil' do + before { digest.update(toponyms: {}) } + + it 'returns 0' do + expect(digest.countries_count).to eq(0) + end + end + end + + describe '#cities_count' do + it 'returns count of cities from toponyms' do + expect(digest.cities_count).to eq(5) # Berlin, Munich, Paris, Madrid, Barcelona + end + + context 'when toponyms cities is nil' do + before { digest.update(toponyms: {}) } + + it 'returns 0' do + expect(digest.cities_count).to eq(0) + end + end + end + + describe '#first_time_countries' do + it 'returns first time countries' do + expect(digest.first_time_countries).to eq(['Spain']) + end + + context 'when first_time_visits countries is nil' do + before { digest.update(first_time_visits: {}) } + + it 'returns empty array' do + expect(digest.first_time_countries).to eq([]) + end + end + end + + describe '#first_time_cities' do + it 'returns first time cities' do + expect(digest.first_time_cities).to eq(%w[Madrid Barcelona]) + end + + context 'when first_time_visits cities is nil' do + before { digest.update(first_time_visits: {}) } + + it 'returns empty array' do + expect(digest.first_time_cities).to eq([]) + end + end + end + + describe '#top_countries_by_time' do + it 'returns countries sorted by time spent' do + expect(digest.top_countries_by_time.first['name']).to eq('Germany') + end + end + + describe '#top_cities_by_time' do + it 'returns cities sorted by time spent' do + expect(digest.top_cities_by_time.first['name']).to eq('Berlin') + end + end + + describe '#yoy_distance_change' do + it 'returns year over year distance change percent' do + expect(digest.yoy_distance_change).to eq(15) + end + + context 'when no previous year data' do + let(:digest) { create(:yearly_digest, :without_previous_year, user: user) } + + it 'returns nil' do + expect(digest.yoy_distance_change).to be_nil + end + end + end + + describe '#previous_year' do + it 'returns previous year' do + expect(digest.previous_year).to eq(2023) + end + end + + describe '#total_countries_all_time' do + it 'returns all time countries count' do + expect(digest.total_countries_all_time).to eq(10) + end + end + + describe '#total_cities_all_time' do + it 'returns all time cities count' do + expect(digest.total_cities_all_time).to eq(45) + end + end + + describe '#total_distance_all_time' do + it 'returns all time distance' do + expect(digest.total_distance_all_time).to eq(2_500_000) + end + end + + describe '#distance_km' do + it 'converts distance from meters to km' do + expect(digest.distance_km).to eq(500.0) + end + end + + describe '#distance_comparison_text' do + context 'when distance is less than Earth circumference' do + it 'returns Earth circumference comparison' do + expect(digest.distance_comparison_text).to include("Earth's circumference") + end + end + + context 'when distance is more than Moon distance' do + before { digest.update(distance: 500_000_000) } # 500k km + + it 'returns Moon distance comparison' do + expect(digest.distance_comparison_text).to include('Moon') + end + end + end + end + + describe 'sharing settings' do + let(:user) { create(:user) } + let(:digest) { create(:yearly_digest, user: user) } + + describe '#sharing_enabled?' do + context 'when sharing_settings is nil' do + before { digest.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(digest.sharing_enabled?).to be false + end + end + + context 'when sharing_settings is empty hash' do + before { digest.update(sharing_settings: {}) } + + it 'returns false' do + expect(digest.sharing_enabled?).to be false + end + end + + context 'when enabled is false' do + before { digest.update(sharing_settings: { 'enabled' => false }) } + + it 'returns false' do + expect(digest.sharing_enabled?).to be false + end + end + + context 'when enabled is true' do + before { digest.update(sharing_settings: { 'enabled' => true }) } + + it 'returns true' do + expect(digest.sharing_enabled?).to be true + end + end + + context 'when enabled is a string "true"' do + before { digest.update(sharing_settings: { 'enabled' => 'true' }) } + + it 'returns false (strict boolean check)' do + expect(digest.sharing_enabled?).to be false + end + end + end + + describe '#sharing_expired?' do + context 'when sharing_settings is nil' do + before { digest.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(digest.sharing_expired?).to be false + end + end + + context 'when expiration is blank' do + before { digest.update(sharing_settings: { 'enabled' => true }) } + + it 'returns false' do + expect(digest.sharing_expired?).to be false + end + end + + context 'when expiration is present but expires_at is blank' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h' + }) + end + + it 'returns true' do + expect(digest.sharing_expired?).to be true + end + end + + context 'when expires_at is in the future' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.from_now.iso8601 + }) + end + + it 'returns false' do + expect(digest.sharing_expired?).to be false + end + end + + context 'when expires_at is in the past' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + }) + end + + it 'returns true' do + expect(digest.sharing_expired?).to be true + end + end + + context 'when expires_at is invalid date string' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 'invalid-date' + }) + end + + it 'returns true (treats as expired)' do + expect(digest.sharing_expired?).to be true + end + end + end + + describe '#public_accessible?' do + context 'when sharing_settings is nil' do + before { digest.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(digest.public_accessible?).to be false + end + end + + context 'when sharing is not enabled' do + before { digest.update(sharing_settings: { 'enabled' => false }) } + + it 'returns false' do + expect(digest.public_accessible?).to be false + end + end + + context 'when sharing is enabled but expired' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + }) + end + + it 'returns false' do + expect(digest.public_accessible?).to be false + end + end + + context 'when sharing is enabled and not expired' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.from_now.iso8601 + }) + end + + it 'returns true' do + expect(digest.public_accessible?).to be true + end + end + + context 'when sharing is enabled with no expiration' do + before do + digest.update(sharing_settings: { 'enabled' => true }) + end + + it 'returns true' do + expect(digest.public_accessible?).to be true + end + end + end + + describe '#enable_sharing!' do + it 'enables sharing with default 24h expiration' do + digest.enable_sharing! + + expect(digest.sharing_enabled?).to be true + expect(digest.sharing_settings['expiration']).to eq('24h') + expect(digest.sharing_uuid).to be_present + end + + it 'enables sharing with custom expiration' do + digest.enable_sharing!(expiration: '1h') + + expect(digest.sharing_settings['expiration']).to eq('1h') + end + + it 'defaults to 24h for invalid expiration' do + digest.enable_sharing!(expiration: 'invalid') + + expect(digest.sharing_settings['expiration']).to eq('24h') + end + end + + describe '#disable_sharing!' do + before { digest.enable_sharing! } + + it 'disables sharing' do + digest.disable_sharing! + + expect(digest.sharing_enabled?).to be false + expect(digest.sharing_settings['expiration']).to be_nil + end + end + + describe '#generate_new_sharing_uuid!' do + it 'generates a new UUID' do + old_uuid = digest.sharing_uuid + digest.generate_new_sharing_uuid! + + expect(digest.sharing_uuid).not_to eq(old_uuid) + end + end + end + + describe 'DistanceConvertible' do + let(:user) { create(:user) } + let(:digest) { create(:yearly_digest, user: user, distance: 10_000) } # 10 km + + describe '#distance_in_unit' do + it 'converts distance to kilometers' do + expect(digest.distance_in_unit('km')).to eq(10.0) + end + + it 'converts distance to miles' do + expect(digest.distance_in_unit('mi').round(2)).to eq(6.21) + end + end + + describe '.convert_distance' do + it 'converts distance to kilometers' do + expect(described_class.convert_distance(10_000, 'km')).to eq(10.0) + end + end + end +end diff --git a/spec/requests/shared/yearly_digests_spec.rb b/spec/requests/shared/yearly_digests_spec.rb new file mode 100644 index 00000000..2e426d06 --- /dev/null +++ b/spec/requests/shared/yearly_digests_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Shared::YearlyDigests', type: :request do + context 'public sharing' do + let(:user) { create(:user) } + let(:digest) { create(:yearly_digest, :with_sharing_enabled, user:, year: 2024) } + + describe 'GET /shared/year/:uuid' do + context 'with valid sharing UUID' do + it 'renders the public year view' do + get shared_yearly_digest_url(digest.sharing_uuid) + + expect(response).to have_http_status(:success) + expect(response.body).to include('Year in Review') + expect(response.body).to include('2024') + end + + it 'includes required content in response' do + get shared_yearly_digest_url(digest.sharing_uuid) + + expect(response.body).to include('2024') + expect(response.body).to include('Distance traveled') + expect(response.body).to include('Countries visited') + end + end + + context 'with invalid sharing UUID' do + it 'redirects to root with alert' do + get shared_yearly_digest_url('invalid-uuid') + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared digest not found or no longer available') + end + end + + context 'with expired sharing' do + let(:digest) { create(:yearly_digest, :with_sharing_expired, user:, year: 2024) } + + it 'redirects to root with alert' do + get shared_yearly_digest_url(digest.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared digest not found or no longer available') + end + end + + context 'with disabled sharing' do + let(:digest) { create(:yearly_digest, :with_sharing_disabled, user:, year: 2024) } + + it 'redirects to root with alert' do + get shared_yearly_digest_url(digest.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared digest not found or no longer available') + end + end + end + + describe 'PATCH /yearly_digests/:year/sharing' do + context 'when user is signed in' do + let!(:digest_to_share) { create(:yearly_digest, user:, year: 2024) } + + before { sign_in user } + + context 'enabling sharing' do + it 'enables sharing and returns success' do + patch sharing_yearly_digest_path(year: 2024), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(true) + expect(json_response['sharing_url']).to be_present + expect(json_response['message']).to eq('Sharing enabled successfully') + + digest_to_share.reload + expect(digest_to_share.sharing_enabled?).to be(true) + expect(digest_to_share.sharing_uuid).to be_present + end + + it 'sets custom expiration when provided' do + patch sharing_yearly_digest_path(year: 2024), + params: { enabled: '1', expiration: '12h' }, + as: :json + + expect(response).to have_http_status(:success) + digest_to_share.reload + expect(digest_to_share.sharing_enabled?).to be(true) + end + end + + context 'disabling sharing' do + let!(:enabled_digest) { create(:yearly_digest, :with_sharing_enabled, user:, year: 2023) } + + it 'disables sharing and returns success' do + patch sharing_yearly_digest_path(year: 2023), + params: { enabled: '0' }, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(true) + expect(json_response['message']).to eq('Sharing disabled successfully') + + enabled_digest.reload + expect(enabled_digest.sharing_enabled?).to be(false) + end + end + + context 'when digest does not exist' do + it 'returns not found' do + patch sharing_yearly_digest_path(year: 2020), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end + + context 'when user is not signed in' do + it 'returns unauthorized' do + patch sharing_yearly_digest_path(year: 2024), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + end +end diff --git a/spec/requests/yearly_digests_spec.rb b/spec/requests/yearly_digests_spec.rb new file mode 100644 index 00000000..f3387763 --- /dev/null +++ b/spec/requests/yearly_digests_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe '/yearly_digests', type: :request do + context 'when user is not signed in' do + describe 'GET /index' do + it 'redirects to the sign in page' do + get yearly_digests_url + + expect(response.status).to eq(302) + end + end + + describe 'GET /show' do + it 'redirects to the sign in page' do + get yearly_digest_url(year: 2024) + + expect(response).to redirect_to(new_user_session_path) + end + end + + describe 'POST /create' do + it 'redirects to the sign in page' do + post yearly_digests_url, params: { year: 2024 } + + expect(response.status).to eq(302) + end + end + end + + context 'when user is signed in' do + let(:user) { create(:user) } + + before { sign_in user } + + describe 'GET /index' do + it 'renders a successful response' do + get yearly_digests_url + + expect(response.status).to eq(200) + end + + it 'displays existing digests' do + digest = create(:yearly_digest, user:, year: 2024) + + get yearly_digests_url + + expect(response.body).to include('2024') + end + + it 'shows empty state when no digests exist' do + get yearly_digests_url + + expect(response.body).to include('No Year-End Digests Yet') + end + end + + describe 'GET /show' do + let!(:digest) { create(:yearly_digest, user:, year: 2024) } + + it 'renders a successful response' do + get yearly_digest_url(year: 2024) + + expect(response.status).to eq(200) + end + + it 'includes digest content' do + get yearly_digest_url(year: 2024) + + expect(response.body).to include('2024 Year in Review') + expect(response.body).to include('Distance Traveled') + end + + it 'redirects when digest not found' do + get yearly_digest_url(year: 2020) + + expect(response).to redirect_to(yearly_digests_path) + expect(flash[:alert]).to eq('Digest not found') + end + end + + describe 'POST /create' do + context 'with valid year' do + before do + create(:stat, user:, year: 2024, month: 1) + end + + it 'enqueues YearlyDigests::CalculatingJob' do + post yearly_digests_url, params: { year: 2024 } + + expect(YearlyDigests::CalculatingJob).to have_been_enqueued.with(user.id, 2024) + end + + it 'redirects with success notice' do + post yearly_digests_url, params: { year: 2024 } + + expect(response).to redirect_to(yearly_digests_path) + expect(flash[:notice]).to include('is being generated') + end + end + + context 'with invalid year' do + it 'redirects with alert for year with no stats' do + post yearly_digests_url, params: { year: 2024 } + + expect(response).to redirect_to(yearly_digests_path) + expect(flash[:alert]).to eq('Invalid year selected') + end + + it 'redirects with alert for year before 2000' do + post yearly_digests_url, params: { year: 1999 } + + expect(response).to redirect_to(yearly_digests_path) + expect(flash[:alert]).to eq('Invalid year selected') + end + + it 'redirects with alert for future year' do + post yearly_digests_url, params: { year: Time.current.year + 1 } + + expect(response).to redirect_to(yearly_digests_path) + expect(flash[:alert]).to eq('Invalid year selected') + end + end + + context 'when user is inactive' do + before do + create(:stat, user:, year: 2024, month: 1) + user.update(status: :inactive, active_until: 1.day.ago) + end + + it 'returns an unauthorized response' do + post yearly_digests_url, params: { year: 2024 } + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end + end + end +end diff --git a/spec/services/yearly_digests/calculate_year_spec.rb b/spec/services/yearly_digests/calculate_year_spec.rb new file mode 100644 index 00000000..47f47ac2 --- /dev/null +++ b/spec/services/yearly_digests/calculate_year_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigests::CalculateYear do + describe '#call' do + subject(:calculate_digest) { described_class.new(user.id, year).call } + + let(:user) { create(:user) } + let(:year) { 2024 } + + context 'when user has no stats for the year' do + it 'returns nil' do + expect(calculate_digest).to be_nil + end + + it 'does not create a digest' do + expect { calculate_digest }.not_to(change { YearlyDigest.count }) + end + end + + context 'when user has stats for the year' do + let!(:january_stat) do + create(:stat, user: user, year: 2024, month: 1, distance: 50_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [ + { 'city' => 'Berlin', 'stayed_for' => 480 }, + { 'city' => 'Munich', 'stayed_for' => 240 } + ] } + ]) + end + + let!(:february_stat) do + create(:stat, user: user, year: 2024, month: 2, distance: 75_000, toponyms: [ + { 'country' => 'France', 'cities' => [ + { 'city' => 'Paris', 'stayed_for' => 360 } + ] } + ]) + end + + it 'creates a yearly digest' do + expect { calculate_digest }.to change { YearlyDigest.count }.by(1) + end + + it 'returns the created digest' do + expect(calculate_digest).to be_a(YearlyDigest) + end + + it 'sets the correct year' do + expect(calculate_digest.year).to eq(2024) + end + + it 'sets the period type to yearly' do + expect(calculate_digest.period_type).to eq('yearly') + end + + it 'calculates total distance' do + expect(calculate_digest.distance).to eq(125_000) + end + + it 'aggregates countries' do + expect(calculate_digest.toponyms['countries']).to contain_exactly('France', 'Germany') + end + + it 'aggregates cities' do + expect(calculate_digest.toponyms['cities']).to contain_exactly('Berlin', 'Munich', 'Paris') + end + + it 'builds monthly distances' do + expect(calculate_digest.monthly_distances['1']).to eq(50_000) + expect(calculate_digest.monthly_distances['2']).to eq(75_000) + expect(calculate_digest.monthly_distances['3']).to eq(0) # Missing month + end + + it 'calculates time spent by location' do + countries = calculate_digest.time_spent_by_location['countries'] + cities = calculate_digest.time_spent_by_location['cities'] + + expect(countries.first['name']).to eq('Germany') + expect(countries.first['minutes']).to eq(720) # 480 + 240 + expect(cities.first['name']).to eq('Berlin') + end + + it 'calculates all time stats' do + expect(calculate_digest.all_time_stats['total_distance']).to eq(125_000) + end + + context 'when digest already exists' do + let!(:existing_digest) do + create(:yearly_digest, user: user, year: 2024, period_type: :yearly, distance: 10_000) + end + + it 'updates the existing digest' do + expect { calculate_digest }.not_to(change { YearlyDigest.count }) + end + + it 'updates the distance' do + calculate_digest + expect(existing_digest.reload.distance).to eq(125_000) + end + end + end + + context 'with previous year data for comparison' do + let!(:previous_year_stat) do + create(:stat, user: user, year: 2023, month: 1, distance: 100_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + let!(:current_year_stat) do + create(:stat, user: user, year: 2024, month: 1, distance: 150_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] }, + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + end + + it 'calculates year over year comparison' do + expect(calculate_digest.year_over_year['previous_year']).to eq(2023) + expect(calculate_digest.year_over_year['distance_change_percent']).to eq(50) + end + + it 'identifies first time visits' do + expect(calculate_digest.first_time_visits['countries']).to eq(['France']) + expect(calculate_digest.first_time_visits['cities']).to eq(['Paris']) + end + end + + context 'when user not found' do + it 'raises ActiveRecord::RecordNotFound' do + expect do + described_class.new(999_999, year).call + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/yearly_digests/first_time_visits_calculator_spec.rb b/spec/services/yearly_digests/first_time_visits_calculator_spec.rb new file mode 100644 index 00000000..37413c82 --- /dev/null +++ b/spec/services/yearly_digests/first_time_visits_calculator_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigests::FirstTimeVisitsCalculator do + describe '#call' do + subject(:calculator) { described_class.new(user, year).call } + + let(:user) { create(:user) } + let(:year) { 2024 } + + context 'when user has no previous years' do + let!(:current_year_stats) do + [ + create(:stat, user: user, year: 2024, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]), + create(:stat, user: user, year: 2024, month: 2, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + ] + end + + it 'returns all countries as first time' do + expect(calculator['countries']).to contain_exactly('France', 'Germany') + end + + it 'returns all cities as first time' do + expect(calculator['cities']).to contain_exactly('Berlin', 'Paris') + end + end + + context 'when user has previous years data' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + let!(:current_year_stats) do + [ + create(:stat, user: user, year: 2024, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] } + ]), + create(:stat, user: user, year: 2024, month: 2, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + ] + end + + it 'returns only new countries as first time' do + expect(calculator['countries']).to eq(['France']) + end + + it 'returns only new cities as first time' do + expect(calculator['cities']).to contain_exactly('Munich', 'Paris') + end + end + + context 'when user has multiple previous years' do + let!(:stats_2022) do + create(:stat, user: user, year: 2022, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + let!(:stats_2023) do + create(:stat, user: user, year: 2023, month: 1, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, toponyms: [ + { 'country' => 'Spain', 'cities' => [{ 'city' => 'Madrid' }] }, + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + it 'considers all previous years when determining first time visits' do + expect(calculator['countries']).to eq(['Spain']) + expect(calculator['cities']).to eq(['Madrid']) + end + end + + context 'when user has no stats for current year' do + it 'returns empty arrays' do + expect(calculator['countries']).to eq([]) + expect(calculator['cities']).to eq([]) + end + end + + context 'when toponyms have invalid format' do + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, toponyms: nil) + end + + it 'handles nil toponyms gracefully' do + expect(calculator['countries']).to eq([]) + expect(calculator['cities']).to eq([]) + end + end + end +end diff --git a/spec/services/yearly_digests/year_over_year_calculator_spec.rb b/spec/services/yearly_digests/year_over_year_calculator_spec.rb new file mode 100644 index 00000000..78255f10 --- /dev/null +++ b/spec/services/yearly_digests/year_over_year_calculator_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe YearlyDigests::YearOverYearCalculator do + describe '#call' do + subject(:calculator) { described_class.new(user, year).call } + + let(:user) { create(:user) } + let(:year) { 2024 } + + context 'when user has no previous year data' do + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000) + end + + it 'returns empty hash' do + expect(calculator).to eq({}) + end + end + + context 'when user has previous year data' do + let!(:previous_year_stats) do + [ + create(:stat, user: user, year: 2023, month: 1, distance: 50_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]), + create(:stat, user: user, year: 2023, month: 2, distance: 50_000, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + ] + end + + let!(:current_year_stats) do + [ + create(:stat, user: user, year: 2024, month: 1, distance: 75_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] } + ]), + create(:stat, user: user, year: 2024, month: 2, distance: 75_000, toponyms: [ + { 'country' => 'Spain', 'cities' => [{ 'city' => 'Madrid' }] } + ]) + ] + end + + it 'returns previous year' do + expect(calculator['previous_year']).to eq(2023) + end + + it 'calculates distance change percent' do + # Previous: 100,000m, Current: 150,000m = 50% increase + expect(calculator['distance_change_percent']).to eq(50) + end + + it 'calculates countries change' do + # Previous: 2 (Germany, France), Current: 2 (Germany, Spain) + expect(calculator['countries_change']).to eq(0) + end + + it 'calculates cities change' do + # Previous: 2 (Berlin, Paris), Current: 3 (Berlin, Munich, Madrid) + expect(calculator['cities_change']).to eq(1) + end + end + + context 'when distance decreased' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, distance: 200_000) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000) + end + + it 'returns negative distance change percent' do + expect(calculator['distance_change_percent']).to eq(-50) + end + end + + context 'when previous year distance is zero' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, distance: 0) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000) + end + + it 'returns nil for distance change percent' do + expect(calculator['distance_change_percent']).to be_nil + end + end + + context 'when countries and cities decreased' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, distance: 100_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] }, + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + it 'returns negative countries change' do + expect(calculator['countries_change']).to eq(-1) + end + + it 'returns negative cities change' do + expect(calculator['cities_change']).to eq(-2) + end + end + end +end