mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Add yearly digest (#2073)
* Add yearly digest * Rename YearlyDigests to Users::Digests * Minor changes * Update yearly digest layout and styles * Add flags and chart to email * Update colors * Fix layout of stats in yearly digest view * Remove cron job for yearly digest scheduling * Update CHANGELOG.md * Update digest email setting handling * Allow sharing digest for 1 week or 1 month * Change Digests Distance to Bigint * Fix settings page
This commit is contained in:
parent
e857f520cc
commit
18b13fb915
46 changed files with 3270 additions and 64 deletions
|
|
@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
# [0.36.5] - Unreleased
|
# [0.36.5] - Unreleased
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- In the beginning of the year users will receive a year-end digest email with stats about their tracking activity during the past year. Users can opt out of receiving these emails in User Settings -> Notifications. Emails won't be sent if no email is configured in the SMTP settings or if user has no points tracked during the year.
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
|
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1
app/assets/svg/icons/lucide/outline/calendar-plus-2.svg
Normal file
1
app/assets/svg/icons/lucide/outline/calendar-plus-2.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-plus2-icon lucide-calendar-plus-2"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M10 16h4"/><path d="M12 14v4"/></svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
1
app/assets/svg/icons/lucide/outline/mail.svg
Normal file
1
app/assets/svg/icons/lucide/outline/mail.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
|
|
@ -35,7 +35,7 @@ class SettingsController < ApplicationController
|
||||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||||
:visits_suggestions_enabled
|
:visits_suggestions_enabled, :digest_emails_enabled
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
55
app/controllers/shared/digests_controller.rb
Normal file
55
app/controllers/shared/digests_controller.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Shared::DigestsController < ApplicationController
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
before_action :authenticate_user!, except: [:show]
|
||||||
|
before_action :authenticate_active_user!, only: [:update]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@digest = Users::Digest.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 'users/digests/public_year'
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@year = params[:year].to_i
|
||||||
|
@digest = current_user.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_users_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
|
||||||
53
app/controllers/users/digests_controller.rb
Normal file
53
app/controllers/users/digests_controller.rb
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DigestsController < ApplicationController
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :authenticate_active_user!, only: [:create]
|
||||||
|
before_action :set_digest, only: [:show]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@digests = current_user.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)
|
||||||
|
Users::Digests::CalculatingJob.perform_later(current_user.id, year)
|
||||||
|
redirect_to users_digests_path,
|
||||||
|
notice: "Year-end digest for #{year} is being generated. Check back soon!",
|
||||||
|
status: :see_other
|
||||||
|
else
|
||||||
|
redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_digest
|
||||||
|
@digest = current_user.digests.yearly.find_by!(year: params[:year])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to users_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.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
|
||||||
50
app/helpers/users/digests_helper.rb
Normal file
50
app/helpers/users/digests_helper.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module DigestsHelper
|
||||||
|
def distance_with_unit(distance_meters, unit)
|
||||||
|
value = Users::Digest.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 >= Users::Digest::MOON_DISTANCE_KM
|
||||||
|
percentage = ((distance_km / Users::Digest::MOON_DISTANCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of the distance to the Moon!"
|
||||||
|
else
|
||||||
|
percentage = ((distance_km / Users::Digest::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
|
||||||
|
end
|
||||||
26
app/jobs/users/digests/calculating_job.rb
Normal file
26
app/jobs/users/digests/calculating_job.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digests::CalculatingJob < ApplicationJob
|
||||||
|
queue_as :digests
|
||||||
|
|
||||||
|
def perform(user_id, year)
|
||||||
|
Users::Digests::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
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
31
app/jobs/users/digests/email_sending_job.rb
Normal file
31
app/jobs/users/digests/email_sending_job.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digests::EmailSendingJob < ApplicationJob
|
||||||
|
queue_as :mailers
|
||||||
|
|
||||||
|
def perform(user_id, year)
|
||||||
|
user = User.find(user_id)
|
||||||
|
digest = user.digests.yearly.find_by(year: year)
|
||||||
|
|
||||||
|
return unless should_send_email?(user, digest)
|
||||||
|
|
||||||
|
Users::DigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later
|
||||||
|
|
||||||
|
digest.update!(sent_at: Time.current)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
ExceptionReporter.call(
|
||||||
|
'Users::Digests::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 if digest.blank?
|
||||||
|
return false if digest.sent_at.present?
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
20
app/jobs/users/digests/year_end_scheduling_job.rb
Normal file
20
app/jobs/users/digests/year_end_scheduling_job.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digests::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
|
||||||
|
Users::Digests::CalculatingJob.perform_later(user.id, year)
|
||||||
|
|
||||||
|
# Schedule email with delay to allow calculation to complete
|
||||||
|
Users::Digests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
17
app/mailers/users/digests_mailer.rb
Normal file
17
app/mailers/users/digests_mailer.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DigestsMailer < ApplicationMailer
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
def year_end_digest
|
||||||
|
@user = params[:user]
|
||||||
|
@digest = params[:digest]
|
||||||
|
@distance_unit = @user.safe_settings.distance_unit || 'km'
|
||||||
|
|
||||||
|
mail(
|
||||||
|
to: @user.email,
|
||||||
|
subject: "Your #{@digest.year} Year in Review - Dawarich"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -68,12 +68,14 @@ class Stat < ApplicationRecord
|
||||||
|
|
||||||
def enable_sharing!(expiration: '1h')
|
def enable_sharing!(expiration: '1h')
|
||||||
# Default to 24h if an invalid expiration is provided
|
# Default to 24h if an invalid expiration is provided
|
||||||
expiration = '24h' unless %w[1h 12h 24h].include?(expiration)
|
expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration)
|
||||||
|
|
||||||
expires_at = case expiration
|
expires_at = case expiration
|
||||||
when '1h' then 1.hour.from_now
|
when '1h' then 1.hour.from_now
|
||||||
when '12h' then 12.hours.from_now
|
when '12h' then 12.hours.from_now
|
||||||
when '24h' then 24.hours.from_now
|
when '24h' then 24.hours.from_now
|
||||||
|
when '1w' then 1.week.from_now
|
||||||
|
when '1m' then 1.month.from_now
|
||||||
end
|
end
|
||||||
|
|
||||||
update!(
|
update!(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
has_many :trips, dependent: :destroy
|
has_many :trips, dependent: :destroy
|
||||||
has_many :tracks, dependent: :destroy
|
has_many :tracks, dependent: :destroy
|
||||||
has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy
|
has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy
|
||||||
|
has_many :digests, class_name: 'Users::Digest', dependent: :destroy
|
||||||
|
|
||||||
after_create :create_api_key
|
after_create :create_api_key
|
||||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||||
|
|
|
||||||
154
app/models/users/digest.rb
Normal file
154
app/models/users/digest.rb
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digest < ApplicationRecord
|
||||||
|
self.table_name = 'digests'
|
||||||
|
|
||||||
|
include DistanceConvertible
|
||||||
|
|
||||||
|
EARTH_CIRCUMFERENCE_KM = 40_075
|
||||||
|
MOON_DISTANCE_KM = 384_400
|
||||||
|
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
validates :year, :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 }
|
||||||
|
|
||||||
|
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 1w 1m].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
|
||||||
|
when '1w' then 1.week.from_now
|
||||||
|
when '1m' then 1.month.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
|
||||||
|
|
||||||
|
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).to_i
|
||||||
|
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
|
||||||
139
app/services/users/digests/calculate_year.rb
Normal file
139
app/services/users/digests/calculate_year.rb
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module Digests
|
||||||
|
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 = Users::Digest.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
|
||||||
|
country_cities = Hash.new { |h, k| h[k] = Set.new }
|
||||||
|
|
||||||
|
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 country.present?
|
||||||
|
|
||||||
|
if toponym['cities'].is_a?(Array)
|
||||||
|
toponym['cities'].each do |city|
|
||||||
|
city_name = city['city'] if city.is_a?(Hash)
|
||||||
|
country_cities[country].add(city_name) if city_name.present?
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# Ensure country appears even if no cities
|
||||||
|
country_cities[country]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
country_cities.sort_by { |country, _| country }.map do |country, cities|
|
||||||
|
{
|
||||||
|
'country' => country,
|
||||||
|
'cities' => cities.to_a.sort.map { |city| { 'city' => city } }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_monthly_distances
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
monthly_stats.each do |stat|
|
||||||
|
result[stat.month.to_s] = stat.distance.to_s
|
||||||
|
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).to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
77
app/services/users/digests/first_time_visits_calculator.rb
Normal file
77
app/services/users/digests/first_time_visits_calculator.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module Digests
|
||||||
|
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) && t['country'].present? }
|
||||||
|
end.uniq
|
||||||
|
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) && c['city'].present? }
|
||||||
|
end
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
79
app/services/users/digests/year_over_year_calculator.rb
Normal file
79
app/services/users/digests/year_over_year_calculator.rb
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module Digests
|
||||||
|
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) && t['country'].present? }
|
||||||
|
end.uniq.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) && c['city'].present? }
|
||||||
|
end
|
||||||
|
end.uniq.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -20,8 +20,9 @@ class Users::SafeSettings
|
||||||
'photoprism_api_key' => nil,
|
'photoprism_api_key' => nil,
|
||||||
'maps' => { 'distance_unit' => 'km' },
|
'maps' => { 'distance_unit' => 'km' },
|
||||||
'visits_suggestions_enabled' => 'true',
|
'visits_suggestions_enabled' => 'true',
|
||||||
'enabled_map_layers' => ['Routes', 'Heatmap'],
|
'enabled_map_layers' => %w[Routes Heatmap],
|
||||||
'maps_maplibre_style' => 'light'
|
'maps_maplibre_style' => 'light',
|
||||||
|
'digest_emails_enabled' => true
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(settings = {})
|
def initialize(settings = {})
|
||||||
|
|
@ -139,4 +140,11 @@ class Users::SafeSettings
|
||||||
def maps_maplibre_style
|
def maps_maplibre_style
|
||||||
settings['maps_maplibre_style']
|
settings['maps_maplibre_style']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def digest_emails_enabled?
|
||||||
|
value = settings['digest_emails_enabled']
|
||||||
|
return true if value.nil?
|
||||||
|
|
||||||
|
ActiveModel::Type::Boolean.new.cast(value)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||||
|
<%= icon 'mail', class: "text-primary mr-2" %> Email Preferences
|
||||||
|
</h2>
|
||||||
|
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-4">
|
||||||
|
<%= f.check_box :digest_emails_enabled,
|
||||||
|
checked: current_user.safe_settings.digest_emails_enabled?,
|
||||||
|
class: "toggle toggle-primary" %>
|
||||||
|
<div>
|
||||||
|
<span class="label-text font-medium">Year-End Digest Emails</span>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
Receive an annual summary email on January 1st with your year in review
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>
|
<% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@
|
||||||
<%= options_for_select([
|
<%= options_for_select([
|
||||||
['1 hour', '1h'],
|
['1 hour', '1h'],
|
||||||
['12 hours', '12h'],
|
['12 hours', '12h'],
|
||||||
['24 hours', '24h']
|
['24 hours', '24h'],
|
||||||
|
['1 week', '1w'],
|
||||||
|
['1 month', '1m']
|
||||||
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
|
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<% content_for :title, 'Statistics' %>
|
<% content_for :title, 'Statistics' %>
|
||||||
|
|
||||||
<div class="w-full my-5">
|
<div class="w-full my-5">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Statistics</h1>
|
||||||
|
<% if Date.today >= Date.new(2025, 12, 31) %>
|
||||||
|
<%= link_to users_digests_path, class: 'btn btn-outline btn-sm' do %>
|
||||||
|
<%= icon 'earth' %> Year-End Digests
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
|
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
|
||||||
<div class="stat text-center">
|
<div class="stat text-center">
|
||||||
<div class="stat-value text-primary">
|
<div class="stat-value text-primary">
|
||||||
|
|
|
||||||
92
app/views/users/digests/index.html.erb
Normal file
92
app/views/users/digests/index.html.erb
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<% content_for :title, 'Year-End Digests' %>
|
||||||
|
|
||||||
|
<div class="max-w-screen-2xl mx-auto my-5 px-4">
|
||||||
|
<div class="flex justify-between items-center mb-6 gap-8">
|
||||||
|
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<%= icon 'earth' %> Year-End Digests
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<% if @available_years.any? && current_user.active? %>
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<label tabindex="0" class="btn btn-primary">
|
||||||
|
<%= icon 'calendar-plus-2' %> Generate Digest
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
|
<% @available_years.each do |year| %>
|
||||||
|
<li>
|
||||||
|
<%= link_to year, users_digests_path(year: year),
|
||||||
|
data: { turbo_method: :post },
|
||||||
|
class: 'text-base' %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @digests.empty? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body text-center py-12">
|
||||||
|
<h2 class="text-xl font-semibold mb-2 flex items-center justify-center gap-2">
|
||||||
|
<%= icon 'earth' %>No Year-End Digests Yet
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 mb-4">
|
||||||
|
Year-end digests are automatically generated on January 1st each year.
|
||||||
|
<% if @available_years.any? && current_user.active? %>
|
||||||
|
<br>Or you can manually generate one for a previous year.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<% @digests.each do |digest| %>
|
||||||
|
<div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl justify-between">
|
||||||
|
<%= link_to digest.year, users_digest_path(year: digest.year), class: 'hover:text-primary' %>
|
||||||
|
<% if digest.sharing_enabled? %>
|
||||||
|
<span class="badge badge-success badge-sm">Shared</span>
|
||||||
|
<% end %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="stats stats-vertical shadow bg-base-100 mt-4 text-center">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Distance</div>
|
||||||
|
<div class="stat-value text-primary text-lg">
|
||||||
|
<%= distance_with_unit(digest.distance, current_user.safe_settings.distance_unit) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value text-secondary text-lg"><%= digest.countries_count %></div>
|
||||||
|
<div class="stat-title">Countries</div>
|
||||||
|
<% if digest.first_time_countries.any? %>
|
||||||
|
<div class="stat-desc text-success flex items-center gap-1 justify-center">
|
||||||
|
<%= icon 'star' %> <%= digest.first_time_countries.count %> new
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value text-accent text-lg"><%= digest.cities_count %></div>
|
||||||
|
<div class="stat-title">Cities</div>
|
||||||
|
<% if digest.first_time_cities.any? %>
|
||||||
|
<div class="stat-desc text-success flex items-center gap-1 justify-center">
|
||||||
|
<%= icon 'star' %> <%= digest.first_time_cities.count %> new
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<%= link_to users_digest_path(year: digest.year), class: 'btn btn-primary btn-sm' do %>
|
||||||
|
View Details
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
189
app/views/users/digests/public_year.html.erb
Normal file
189
app/views/users/digests/public_year.html.erb
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<div class="max-w-xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background: linear-gradient(135deg, #0f766e, #0284c7);">
|
||||||
|
<div class="hero-content text-center py-12">
|
||||||
|
<div class="max-w-lg">
|
||||||
|
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
|
||||||
|
<p class="py-4">A journey, by the numbers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distance Card -->
|
||||||
|
<div class="stats shadow mx-auto mb-8 w-full">
|
||||||
|
<div class="stat place-items-center text-center">
|
||||||
|
<div class="stat-title">Distance traveled</div>
|
||||||
|
<div class="stat-value"><%= distance_with_unit(@digest.distance, @distance_unit) %></div>
|
||||||
|
<div class="stat-desc"><%= distance_comparison_text(@digest.distance) %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat place-items-center text-center">
|
||||||
|
<div class="stat-title">Countries visited</div>
|
||||||
|
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
|
||||||
|
<div class="stat-desc <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat place-items-center text-center">
|
||||||
|
<div class="stat-title">Cities explored</div>
|
||||||
|
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
|
||||||
|
<div class="stat-desc <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Time Visits -->
|
||||||
|
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'star' %> First Time Visits
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-semibold mb-2">New Countries</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_countries.each do |country| %>
|
||||||
|
<span class="badge badge-success badge-lg"><%= country %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-2">New Cities</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_cities.take(5).each do |city| %>
|
||||||
|
<span class="badge badge-outline"><%= city %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @digest.first_time_cities.count > 5 %>
|
||||||
|
<span class="badge badge-ghost">+<%= @digest.first_time_cities.count - 5 %> more</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Monthly Distance Chart -->
|
||||||
|
<% if @digest.monthly_distances.present? %>
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'activity' %> Year by Month
|
||||||
|
</h2>
|
||||||
|
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
|
||||||
|
<%= column_chart(
|
||||||
|
@digest.monthly_distances.sort.map { |month, distance_meters|
|
||||||
|
[Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @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'
|
||||||
|
]
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Top Countries by Time Spent -->
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'map-pin' %> Where They Spent the Most Time
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-2 w-full">
|
||||||
|
<% @digest.top_countries_by_time.take(3).each do |country| %>
|
||||||
|
<li class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['name']) %></span>
|
||||||
|
<%= country['name'] %>
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Countries & Cities -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'earth' %> Countries & Cities
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 w-full">
|
||||||
|
<% @digest.toponyms&.each_with_index do |country, index| %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['country']) %></span>
|
||||||
|
<%= country['country'] %>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm"><%= country['cities']&.length || 0 %> cities</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center w-full">
|
||||||
|
<span class="text-sm font-medium">Cities visited:</span>
|
||||||
|
<% @digest.toponyms&.each do |country| %>
|
||||||
|
<% country['cities']&.take(5)&.each do |city| %>
|
||||||
|
<div class="badge badge-outline"><%= city['city'] %></div>
|
||||||
|
<% end %>
|
||||||
|
<% if country['cities']&.length.to_i > 5 %>
|
||||||
|
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All-Time Stats -->
|
||||||
|
<div class="card bg-slate-800 text-white shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title text-white">
|
||||||
|
<%= icon 'trophy' %> All-Time Stats
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Countries visited</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Cities explored</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center mt-2">
|
||||||
|
<div class="stat-title text-gray-400">Total distance</div>
|
||||||
|
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Powered by <a href="https://dawarich.app" class="link link-primary" target="_blank">Dawarich</a>, your personal memories mapper.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
317
app/views/users/digests/show.html.erb
Normal file
317
app/views/users/digests/show.html.erb
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
<% content_for :title, "#{@digest.year} Year in Review" %>
|
||||||
|
|
||||||
|
<div class="max-w-xl mx-auto my-5">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background: linear-gradient(135deg, #0f766e, #0284c7);">
|
||||||
|
<div class="hero-content text-center py-12 relative w-full">
|
||||||
|
<div class="max-w-lg">
|
||||||
|
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
|
||||||
|
<p class="py-4">Your journey, by the numbers</p>
|
||||||
|
<button class="btn btn-outline btn-sm text-neutral border-neutral hover:bg-white hover:text-primary"
|
||||||
|
onclick="sharing_modal.showModal()">
|
||||||
|
<%= icon 'share' %> Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distance Card -->
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<div class="stat-title flex items-center gap-2">
|
||||||
|
<%= icon 'map' %> Distance Traveled
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-primary text-4xl my-4">
|
||||||
|
<%= distance_with_unit(@digest.distance, @distance_unit) %>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600"><%= distance_comparison_text(@digest.distance) %></p>
|
||||||
|
<% if @digest.yoy_distance_change %>
|
||||||
|
<p class="mt-2 font-semibold <%= yoy_change_class(@digest.yoy_distance_change) %>">
|
||||||
|
<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Row -->
|
||||||
|
<div class="stats shadow w-full mb-8 bg-base-200">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title flex items-center gap-1">
|
||||||
|
<%= icon 'globe' %> Countries
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
|
||||||
|
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= icon 'star' %> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title flex items-center gap-1">
|
||||||
|
<%= icon 'building' %> Cities
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
|
||||||
|
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= icon 'star' %> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Time Visits -->
|
||||||
|
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'star' %> First Time Visits
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-semibold mb-2">New Countries</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_countries.each do |country| %>
|
||||||
|
<span class="badge badge-success badge-lg"><%= country %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-2">New Cities</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_cities.take(10).each do |city| %>
|
||||||
|
<span class="badge badge-outline"><%= city %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @digest.first_time_cities.count > 10 %>
|
||||||
|
<span class="badge badge-ghost">+<%= @digest.first_time_cities.count - 10 %> more</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Monthly Distance Chart -->
|
||||||
|
<% if @digest.monthly_distances.present? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'activity' %> Your Year, Month by Month
|
||||||
|
</h2>
|
||||||
|
<div class="w-full h-64 bg-base-100 rounded-lg p-4">
|
||||||
|
<%= column_chart(
|
||||||
|
@digest.monthly_distances.sort.map { |month, distance_meters|
|
||||||
|
[Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @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'
|
||||||
|
]
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Top Countries by Time Spent -->
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'map-pin' %> Where You Spent the Most Time
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 w-full">
|
||||||
|
<% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %>
|
||||||
|
<div class="flex justify-between items-center p-3 bg-base-100 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="badge badge-lg <%= ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success'][index] %>">
|
||||||
|
<%= index + 1 %>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['name']) %></span>
|
||||||
|
<%= country['name'] %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- All Countries & Cities -->
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'earth' %> Countries & Cities
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 w-full">
|
||||||
|
<% 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] %>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['country']) %></span>
|
||||||
|
<%= country['country'] %>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm">
|
||||||
|
<%= pluralize(cities_count, 'city') %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress <%= color_class %> w-full" value="<%= progress_value %>" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-500">No location data available</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All-Time Stats Footer -->
|
||||||
|
<div class="card bg-slate-800 text-white shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title text-white">
|
||||||
|
<%= icon 'trophy' %> All-Time Stats
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Countries visited</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Cities explored</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center mt-2">
|
||||||
|
<div class="stat-title text-gray-400">Total distance</div>
|
||||||
|
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-4 justify-center">
|
||||||
|
<%= link_to users_digests_path, class: 'btn btn-outline' do %>
|
||||||
|
Back to All Digests
|
||||||
|
<% end %>
|
||||||
|
<button class="btn btn-outline" onclick="sharing_modal.showModal()">
|
||||||
|
<%= icon 'share' %> Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sharing Modal -->
|
||||||
|
<dialog id="sharing_modal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
|
||||||
|
<%= icon 'link' %> Sharing Settings
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div data-controller="sharing-modal"
|
||||||
|
data-sharing-modal-url-value="<%= sharing_users_digest_path(year: @digest.year) %>">
|
||||||
|
|
||||||
|
<!-- Enable/Disable Sharing Toggle -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text font-medium">Enable public access</span>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="enabled"
|
||||||
|
<%= 'checked' if @digest.sharing_enabled? %>
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
data-action="change->sharing-modal#toggleSharing"
|
||||||
|
data-sharing-modal-target="enableToggle" />
|
||||||
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-gray-500">Allow others to view this year-end digest • Auto-saves on change</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiration Settings (shown when enabled) -->
|
||||||
|
<div data-sharing-modal-target="expirationSettings"
|
||||||
|
class="<%= 'hidden' unless @digest.sharing_enabled? %>">
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Link expiration</span>
|
||||||
|
</label>
|
||||||
|
<select name="expiration"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
data-sharing-modal-target="expirationSelect"
|
||||||
|
data-action="change->sharing-modal#expirationChanged">
|
||||||
|
<%= options_for_select([
|
||||||
|
['1 hour', '1h'],
|
||||||
|
['12 hours', '12h'],
|
||||||
|
['24 hours', '24h'],
|
||||||
|
['1 week', '1w'],
|
||||||
|
['1 month', '1m']
|
||||||
|
], @digest&.sharing_settings&.dig('expiration') || '24h') %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sharing Link Display -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Sharing link</span>
|
||||||
|
</label>
|
||||||
|
<div class="join w-full">
|
||||||
|
<input type="text"
|
||||||
|
readonly
|
||||||
|
class="input input-bordered join-item flex-1"
|
||||||
|
data-sharing-modal-target="sharingLink"
|
||||||
|
value="<%= @digest.sharing_enabled? ? shared_users_digest_url(@digest.sharing_uuid) : '' %>" />
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline join-item"
|
||||||
|
data-action="click->sharing-modal#copyLink">
|
||||||
|
<%= icon 'copy' %> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-gray-500">Share this link to allow others to view your year-end digest</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy Notice -->
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<%= icon 'info' %>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">Privacy Protection</h3>
|
||||||
|
<div class="text-sm">
|
||||||
|
• Exact coordinates are hidden<br>
|
||||||
|
• Personal information is not included
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick="sharing_modal.close()">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
298
app/views/users/digests_mailer/year_end_digest.html.erb
Normal file
298
app/views/users/digests_mailer/year_end_digest.html.erb
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #0f766e, #0284c7);
|
||||||
|
color: white;
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.header p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 30px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 16px 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2563eb;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-description {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.first-time-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.comparison {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.comparison.positive {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.comparison.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.chart-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.location-list {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.location-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.location-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.all-time-footer {
|
||||||
|
background: #1e293b;
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.all-time-footer h3 {
|
||||||
|
color: white;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.all-time-stat {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.all-time-stat:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.all-time-stat .label {
|
||||||
|
opacity: 0.8;
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.all-time-stat .value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.unsubscribe {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.unsubscribe a {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1><%= @digest.year %> Year in Review</h1>
|
||||||
|
<p>Your journey, by the numbers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
Hi, this is Evgenii from Dawarich! Pretty wild journey last yeah, huh? Let's take a look back at all the places you explored in <strong><%= @digest.year %></strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Distance Traveled -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Distance Traveled</div>
|
||||||
|
<p class="stat-value"><%= distance_with_unit(@digest.distance, @distance_unit) %></p>
|
||||||
|
<p class="stat-description"><%= distance_comparison_text(@digest.distance) %></p>
|
||||||
|
<% if @digest.yoy_distance_change %>
|
||||||
|
<p class="comparison <%= yoy_change_class(@digest.yoy_distance_change) %>">
|
||||||
|
<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countries Visited -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Countries Visited</div>
|
||||||
|
<p class="stat-value"><%= @digest.countries_count %></p>
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
<p class="stat-description">
|
||||||
|
<span class="first-time-badge">New</span>
|
||||||
|
First time in: <%= @digest.first_time_countries.join(', ') %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cities Visited -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Cities Explored</div>
|
||||||
|
<p class="stat-value"><%= @digest.cities_count %></p>
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
<p class="stat-description">
|
||||||
|
<span class="first-time-badge">New</span>
|
||||||
|
<% 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 %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly Distance Chart -->
|
||||||
|
<% if @digest.monthly_distances.present? %>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3 style="margin: 0 0 16px 0; color: #1e293b;">Your Year, Month by Month</h3>
|
||||||
|
<% max_distance = @digest.monthly_distances.values.map(&:to_i).max %>
|
||||||
|
<% max_distance = 1 if max_distance.zero? %>
|
||||||
|
<% chart_height = 120 %>
|
||||||
|
<% bar_colors = ['#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e'] %>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">
|
||||||
|
<!-- Bars row -->
|
||||||
|
<tr>
|
||||||
|
<% (1..12).each do |month| %>
|
||||||
|
<% distance = @digest.monthly_distances[month.to_s].to_i %>
|
||||||
|
<% bar_height = (distance.to_f / max_distance * chart_height).round %>
|
||||||
|
<% bar_height = 3 if bar_height < 3 && distance > 0 %>
|
||||||
|
<td style="vertical-align: bottom; text-align: center; padding: 0 2px;">
|
||||||
|
<div style="background: <%= bar_colors[month - 1] %>; width: 100%; height: <%= bar_height %>px; border-radius: 3px 3px 0 0; min-height: 3px;"></div>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
<!-- Labels row -->
|
||||||
|
<tr>
|
||||||
|
<% (1..12).each do |month| %>
|
||||||
|
<td style="text-align: center; padding-top: 6px; font-size: 11px; color: #64748b;">
|
||||||
|
<%= Date::ABBR_MONTHNAMES[month][0..0] %>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Top Locations by Time Spent -->
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Where You Spent the Most Time</div>
|
||||||
|
<ul class="location-list">
|
||||||
|
<% @digest.top_countries_by_time.take(3).each do |country| %>
|
||||||
|
<li>
|
||||||
|
<span><%= country_flag(country['name']) %> <%= country['name'] %></span>
|
||||||
|
<span><%= format_time_spent(country['minutes']) %></span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- All-Time Stats Footer -->
|
||||||
|
<div class="all-time-footer">
|
||||||
|
<h3>All-Time Stats</h3>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 16px;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="text-align: center; padding: 8px;">
|
||||||
|
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Countries visited</div>
|
||||||
|
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_countries_all_time %></div>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="text-align: center; padding: 8px;">
|
||||||
|
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Cities explored</div>
|
||||||
|
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_cities_all_time %></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="all-time-stat" style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;">
|
||||||
|
<span class="label">Total distance</span>
|
||||||
|
<span class="value"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
You can open your digest for sharing on its page on Dawarich: <a href="<%= users_digest_url(year: @digest.year) %>"><%= users_digest_url(year: @digest.year) %></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Powered by <a href="https://dawarich.app">Dawarich</a>, your personal location history.</p>
|
||||||
|
<p class="unsubscribe">
|
||||||
|
You can <a href="<%= settings_url(host: ENV.fetch('DOMAIN', 'localhost')) %>">manage your email preferences</a> in settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
app/views/users/digests_mailer/year_end_digest.text.erb
Normal file
41
app/views/users/digests_mailer/year_end_digest.text.erb
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<%= @digest.year %> Year in Review
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Hi, this is Evgenii from Dawarich! Pretty wild journey last year, huh? Let's take a look back at all the places you explored in <%= @digest.year %>.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Keep exploring, keep discovering. Here's to even more adventures in <%= @digest.year + 1 %>!
|
||||||
|
|
||||||
|
--
|
||||||
|
Powered by Dawarich
|
||||||
|
https://dawarich.app
|
||||||
|
|
||||||
|
Manage your email preferences: <%= settings_url(host: ENV.fetch('DOMAIN', 'localhost')) %>
|
||||||
|
|
@ -98,6 +98,17 @@ Rails.application.routes.draw do
|
||||||
as: :sharing_stats,
|
as: :sharing_stats,
|
||||||
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
|
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
|
||||||
|
|
||||||
|
# User digests routes (yearly/monthly digest reports)
|
||||||
|
scope module: 'users' do
|
||||||
|
resources :digests, only: %i[index create], param: :year, as: :users_digests
|
||||||
|
get 'digests/:year', to: 'digests#show', as: :users_digest, constraints: { year: /\d{4}/ }
|
||||||
|
end
|
||||||
|
get 'shared/digest/:uuid', to: 'shared/digests#show', as: :shared_users_digest
|
||||||
|
patch 'digests/:year/sharing',
|
||||||
|
to: 'shared/digests#update',
|
||||||
|
as: :sharing_users_digest,
|
||||||
|
constraints: { year: /\d{4}/ }
|
||||||
|
|
||||||
root to: 'home#index'
|
root to: 'home#index'
|
||||||
|
|
||||||
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
|
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,4 @@
|
||||||
- app_version_checking
|
- app_version_checking
|
||||||
- cache
|
- cache
|
||||||
- archival
|
- archival
|
||||||
|
- digests
|
||||||
|
|
|
||||||
38
db/migrate/20251227000001_create_digests.rb
Normal file
38
db/migrate/20251227000001_create_digests.rb
Normal file
|
|
@ -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.bigint :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
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ChangeDigestsDistanceToBigint < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
safety_assured { change_column :digests, :distance, :bigint, null: false, default: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
safety_assured { change_column :digests, :distance, :integer, null: false, default: 0 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -46,7 +46,7 @@ if Tag.none?
|
||||||
{ name: 'Home', color: '#FF5733', icon: '🏡' },
|
{ name: 'Home', color: '#FF5733', icon: '🏡' },
|
||||||
{ name: 'Work', color: '#33FF57', icon: '💼' },
|
{ name: 'Work', color: '#33FF57', icon: '💼' },
|
||||||
{ name: 'Favorite', color: '#3357FF', icon: '⭐' },
|
{ name: 'Favorite', color: '#3357FF', icon: '⭐' },
|
||||||
{ name: 'Travel Plans', color: '#F1C40F', icon: '🗺️' },
|
{ name: 'Travel Plans', color: '#F1C40F', icon: '🗺️' }
|
||||||
]
|
]
|
||||||
|
|
||||||
User.find_each do |user|
|
User.find_each do |user|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace :points do
|
namespace :points do
|
||||||
namespace :raw_data do
|
namespace :raw_data do
|
||||||
desc 'Restore raw_data from archive to database for a specific month'
|
desc 'Restore raw_data from archive to database for a specific month'
|
||||||
task :restore, [:user_id, :year, :month] => :environment do |_t, args|
|
task :restore, %i[user_id year month] => :environment do |_t, args|
|
||||||
validate_args!(args)
|
validate_args!(args)
|
||||||
|
|
||||||
user_id = args[:user_id].to_i
|
user_id = args[:user_id].to_i
|
||||||
|
|
@ -27,7 +27,7 @@ namespace :points do
|
||||||
end
|
end
|
||||||
|
|
||||||
desc 'Restore raw_data to memory/cache temporarily (for data migrations)'
|
desc 'Restore raw_data to memory/cache temporarily (for data migrations)'
|
||||||
task :restore_temporary, [:user_id, :year, :month] => :environment do |_t, args|
|
task :restore_temporary, %i[user_id year month] => :environment do |_t, args|
|
||||||
validate_args!(args)
|
validate_args!(args)
|
||||||
|
|
||||||
user_id = args[:user_id].to_i
|
user_id = args[:user_id].to_i
|
||||||
|
|
@ -69,9 +69,9 @@ namespace :points do
|
||||||
puts ''
|
puts ''
|
||||||
|
|
||||||
archives = Points::RawDataArchive.where(user_id: user_id)
|
archives = Points::RawDataArchive.where(user_id: user_id)
|
||||||
.select(:year, :month)
|
.select(:year, :month)
|
||||||
.distinct
|
.distinct
|
||||||
.order(:year, :month)
|
.order(:year, :month)
|
||||||
|
|
||||||
puts "Found #{archives.count} months to restore"
|
puts "Found #{archives.count} months to restore"
|
||||||
puts ''
|
puts ''
|
||||||
|
|
@ -113,9 +113,9 @@ namespace :points do
|
||||||
|
|
||||||
# Storage size via ActiveStorage
|
# Storage size via ActiveStorage
|
||||||
total_blob_size = ActiveStorage::Blob
|
total_blob_size = ActiveStorage::Blob
|
||||||
.joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id')
|
.joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id')
|
||||||
.where("active_storage_attachments.record_type = 'Points::RawDataArchive'")
|
.where("active_storage_attachments.record_type = 'Points::RawDataArchive'")
|
||||||
.sum(:byte_size)
|
.sum(:byte_size)
|
||||||
|
|
||||||
puts "Storage used: #{ActiveSupport::NumberHelper.number_to_human_size(total_blob_size)}"
|
puts "Storage used: #{ActiveSupport::NumberHelper.number_to_human_size(total_blob_size)}"
|
||||||
puts ''
|
puts ''
|
||||||
|
|
@ -130,10 +130,10 @@ namespace :points do
|
||||||
puts '─────────────────────────────────────────────────'
|
puts '─────────────────────────────────────────────────'
|
||||||
|
|
||||||
Points::RawDataArchive.group(:user_id)
|
Points::RawDataArchive.group(:user_id)
|
||||||
.select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points')
|
.select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points')
|
||||||
.order('archive_count DESC')
|
.order('archive_count DESC')
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.each_with_index do |stat, idx|
|
.each_with_index do |stat, idx|
|
||||||
user = User.find(stat.user_id)
|
user = User.find(stat.user_id)
|
||||||
puts "#{idx + 1}. #{user.email.ljust(30)} #{stat.archive_count.to_s.rjust(3)} archives, #{stat.total_points.to_s.rjust(8)} points"
|
puts "#{idx + 1}. #{user.email.ljust(30)} #{stat.archive_count.to_s.rjust(3)} archives, #{stat.total_points.to_s.rjust(8)} points"
|
||||||
end
|
end
|
||||||
|
|
@ -142,7 +142,7 @@ namespace :points do
|
||||||
end
|
end
|
||||||
|
|
||||||
desc 'Verify archive integrity (all unverified archives, or specific month with args)'
|
desc 'Verify archive integrity (all unverified archives, or specific month with args)'
|
||||||
task :verify, [:user_id, :year, :month] => :environment do |_t, args|
|
task :verify, %i[user_id year month] => :environment do |_t, args|
|
||||||
verifier = Points::RawData::Verifier.new
|
verifier = Points::RawData::Verifier.new
|
||||||
|
|
||||||
if args[:user_id] && args[:year] && args[:month]
|
if args[:user_id] && args[:year] && args[:month]
|
||||||
|
|
@ -177,7 +177,7 @@ namespace :points do
|
||||||
end
|
end
|
||||||
|
|
||||||
desc 'Clear raw_data for verified archives (all verified, or specific month with args)'
|
desc 'Clear raw_data for verified archives (all verified, or specific month with args)'
|
||||||
task :clear_verified, [:user_id, :year, :month] => :environment do |_t, args|
|
task :clear_verified, %i[user_id year month] => :environment do |_t, args|
|
||||||
clearer = Points::RawData::Clearer.new
|
clearer = Points::RawData::Clearer.new
|
||||||
|
|
||||||
if args[:user_id] && args[:year] && args[:month]
|
if args[:user_id] && args[:year] && args[:month]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
namespace :webmanifest do
|
namespace :webmanifest do
|
||||||
desc "Generate site.webmanifest in public directory with correct asset paths"
|
desc 'Generate site.webmanifest in public directory with correct asset paths'
|
||||||
task :generate => :environment do
|
task generate: :environment do
|
||||||
require 'erb'
|
require 'erb'
|
||||||
|
|
||||||
# Make sure assets are compiled first by loading the manifest
|
# Make sure assets are compiled first by loading the manifest
|
||||||
|
|
@ -12,28 +14,28 @@ namespace :webmanifest do
|
||||||
|
|
||||||
# Generate the manifest content
|
# Generate the manifest content
|
||||||
manifest_content = {
|
manifest_content = {
|
||||||
"name": "Dawarich",
|
"name": 'Dawarich',
|
||||||
"short_name": "Dawarich",
|
"short_name": 'Dawarich',
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": icon_192_path,
|
"src": icon_192_path,
|
||||||
"sizes": "192x192",
|
"sizes": '192x192',
|
||||||
"type": "image/png"
|
"type": 'image/png'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": icon_512_path,
|
"src": icon_512_path,
|
||||||
"sizes": "512x512",
|
"sizes": '512x512',
|
||||||
"type": "image/png"
|
"type": 'image/png'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme_color": "#ffffff",
|
"theme_color": '#ffffff',
|
||||||
"background_color": "#ffffff",
|
"background_color": '#ffffff',
|
||||||
"display": "standalone"
|
"display": 'standalone'
|
||||||
}.to_json
|
}.to_json
|
||||||
|
|
||||||
# Write to public/site.webmanifest
|
# Write to public/site.webmanifest
|
||||||
File.write(Rails.root.join('public/site.webmanifest'), manifest_content)
|
File.write(Rails.root.join('public/site.webmanifest'), manifest_content)
|
||||||
puts "Generated public/site.webmanifest with correct asset paths"
|
puts 'Generated public/site.webmanifest with correct asset paths'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
133
spec/factories/users/digests.rb
Normal file
133
spec/factories/users/digests.rb
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :users_digest, class: 'Users::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' => '50000',
|
||||||
|
'2' => '45000',
|
||||||
|
'3' => '60000',
|
||||||
|
'4' => '55000',
|
||||||
|
'5' => '40000',
|
||||||
|
'6' => '35000',
|
||||||
|
'7' => '30000',
|
||||||
|
'8' => '45000',
|
||||||
|
'9' => '50000',
|
||||||
|
'10' => '40000',
|
||||||
|
'11' => '25000',
|
||||||
|
'12' => '25000'
|
||||||
|
}
|
||||||
|
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' => '2500000'
|
||||||
|
}
|
||||||
|
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
|
||||||
49
spec/jobs/users/digests/calculating_job_spec.rb
Normal file
49
spec/jobs/users/digests/calculating_job_spec.rb
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digests::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(Users::Digests::CalculateYear).to receive(:new).and_call_original
|
||||||
|
allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls Users::Digests::CalculateYear service' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::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 Users::Digests::CalculateYear raises an error' do
|
||||||
|
before do
|
||||||
|
allow_any_instance_of(Users::Digests::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(Users::Digests::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
|
||||||
83
spec/jobs/users/digests/email_sending_job_spec.rb
Normal file
83
spec/jobs/users/digests/email_sending_job_spec.rb
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digests::EmailSendingJob, type: :job do
|
||||||
|
describe '#perform' do
|
||||||
|
let!(:user) { create(:user) }
|
||||||
|
let(:year) { 2024 }
|
||||||
|
let!(:digest) { create(:users_digest, user: user, year: year, period_type: :yearly) }
|
||||||
|
|
||||||
|
subject { described_class.perform_now(user.id, year) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock the mailer
|
||||||
|
allow(Users::DigestsMailer).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(Users::DigestsMailer).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(Users::DigestsMailer).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(Users::DigestsMailer).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(Users::DigestsMailer).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(
|
||||||
|
'Users::Digests::EmailSendingJob',
|
||||||
|
anything
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.perform_now(999_999, year)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
110
spec/jobs/users/digests/year_end_scheduling_job_spec.rb
Normal file
110
spec/jobs/users/digests/year_end_scheduling_job_spec.rb
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digests::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(Users::Digests::CalculatingJob).to receive(:perform_later)
|
||||||
|
allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'schedules jobs for active users' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
|
||||||
|
.with(active_user.id, previous_year)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'schedules jobs for trial users' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
|
||||||
|
.with(trial_user.id, previous_year)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not schedule jobs for inactive users' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::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(Users::Digests::EmailSendingJob).to receive(:set)
|
||||||
|
.with(wait: 30.minutes)
|
||||||
|
.and_return(email_job_double)
|
||||||
|
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::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(Users::Digests::CalculatingJob).to receive(:perform_later)
|
||||||
|
allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not schedule jobs for user without stats' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
|
||||||
|
.with(user_without_stats.id, anything)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'schedules jobs for user with stats' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::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(Users::Digests::CalculatingJob).to receive(:perform_later)
|
||||||
|
allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not schedule jobs for that user' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
|
||||||
|
.with(user_current_year_only.id, anything)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
10
spec/mailers/previews/users/digests_mailer_preview.rb
Normal file
10
spec/mailers/previews/users/digests_mailer_preview.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DigestsMailerPreview < ActionMailer::Preview
|
||||||
|
def year_end_digest
|
||||||
|
user = User.first
|
||||||
|
digest = user.digests.yearly.last || Users::Digest.last
|
||||||
|
|
||||||
|
Users::DigestsMailer.with(user: user, digest: digest).year_end_digest
|
||||||
|
end
|
||||||
|
end
|
||||||
429
spec/models/users/digest_spec.rb
Normal file
429
spec/models/users/digest_spec.rb
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digest, 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(:users_digest, user: user, year: 2024, period_type: :yearly) }
|
||||||
|
|
||||||
|
it 'does not allow duplicate yearly digest for same user and year' do
|
||||||
|
duplicate = build(:users_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(:users_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(:users_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(:users_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(:users_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(:users_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(:users_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(:users_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(:users_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
|
||||||
137
spec/requests/shared/digests_spec.rb
Normal file
137
spec/requests/shared/digests_spec.rb
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Shared::Digests', type: :request do
|
||||||
|
context 'public sharing' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:digest) { create(:users_digest, :with_sharing_enabled, user:, year: 2024) }
|
||||||
|
|
||||||
|
describe 'GET /shared/digest/:uuid' do
|
||||||
|
context 'with valid sharing UUID' do
|
||||||
|
it 'renders the public year view' do
|
||||||
|
get shared_users_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_users_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_users_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(:users_digest, :with_sharing_expired, user:, year: 2024) }
|
||||||
|
|
||||||
|
it 'redirects to root with alert' do
|
||||||
|
get shared_users_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(:users_digest, :with_sharing_disabled, user:, year: 2024) }
|
||||||
|
|
||||||
|
it 'redirects to root with alert' do
|
||||||
|
get shared_users_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 /digests/:year/sharing' do
|
||||||
|
context 'when user is signed in' do
|
||||||
|
let!(:digest_to_share) { create(:users_digest, user:, year: 2024) }
|
||||||
|
|
||||||
|
before { sign_in user }
|
||||||
|
|
||||||
|
context 'enabling sharing' do
|
||||||
|
it 'enables sharing and returns success' do
|
||||||
|
patch sharing_users_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_users_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(:users_digest, :with_sharing_enabled, user:, year: 2023) }
|
||||||
|
|
||||||
|
it 'disables sharing and returns success' do
|
||||||
|
patch sharing_users_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_users_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_users_digest_path(year: 2024),
|
||||||
|
params: { enabled: '1' },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
141
spec/requests/users/digests_spec.rb
Normal file
141
spec/requests/users/digests_spec.rb
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe '/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 users_digests_url
|
||||||
|
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /show' do
|
||||||
|
it 'redirects to the sign in page' do
|
||||||
|
get users_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 users_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 users_digests_url
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'displays existing digests' do
|
||||||
|
digest = create(:users_digest, user:, year: 2024)
|
||||||
|
|
||||||
|
get users_digests_url
|
||||||
|
|
||||||
|
expect(response.body).to include('2024')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows empty state when no digests exist' do
|
||||||
|
get users_digests_url
|
||||||
|
|
||||||
|
expect(response.body).to include('No Year-End Digests Yet')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /show' do
|
||||||
|
let!(:digest) { create(:users_digest, user:, year: 2024) }
|
||||||
|
|
||||||
|
it 'renders a successful response' do
|
||||||
|
get users_digest_url(year: 2024)
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes digest content' do
|
||||||
|
get users_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 users_digest_url(year: 2020)
|
||||||
|
|
||||||
|
expect(response).to redirect_to(users_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 Users::Digests::CalculatingJob' do
|
||||||
|
post users_digests_url, params: { year: 2024 }
|
||||||
|
|
||||||
|
expect(Users::Digests::CalculatingJob).to have_been_enqueued.with(user.id, 2024)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects with success notice' do
|
||||||
|
post users_digests_url, params: { year: 2024 }
|
||||||
|
|
||||||
|
expect(response).to redirect_to(users_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 users_digests_url, params: { year: 2024 }
|
||||||
|
|
||||||
|
expect(response).to redirect_to(users_digests_path)
|
||||||
|
expect(flash[:alert]).to eq('Invalid year selected')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects with alert for year before 2000' do
|
||||||
|
post users_digests_url, params: { year: 1999 }
|
||||||
|
|
||||||
|
expect(response).to redirect_to(users_digests_path)
|
||||||
|
expect(flash[:alert]).to eq('Invalid year selected')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects with alert for future year' do
|
||||||
|
post users_digests_url, params: { year: Time.current.year + 1 }
|
||||||
|
|
||||||
|
expect(response).to redirect_to(users_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 users_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
|
||||||
141
spec/services/users/digests/calculate_year_spec.rb
Normal file
141
spec/services/users/digests/calculate_year_spec.rb
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digests::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 { Users::Digest.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 { Users::Digest.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the created digest' do
|
||||||
|
expect(calculate_digest).to be_a(Users::Digest)
|
||||||
|
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 with their cities' do
|
||||||
|
toponyms = calculate_digest.toponyms
|
||||||
|
|
||||||
|
countries = toponyms.map { |t| t['country'] }
|
||||||
|
expect(countries).to contain_exactly('France', 'Germany')
|
||||||
|
|
||||||
|
germany = toponyms.find { |t| t['country'] == 'Germany' }
|
||||||
|
expect(germany['cities'].map { |c| c['city'] }).to contain_exactly('Berlin', 'Munich')
|
||||||
|
|
||||||
|
france = toponyms.find { |t| t['country'] == 'France' }
|
||||||
|
expect(france['cities'].map { |c| c['city'] }).to contain_exactly('Paris')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'builds monthly distances' do
|
||||||
|
expect(calculate_digest.monthly_distances['1']).to eq('50000')
|
||||||
|
expect(calculate_digest.monthly_distances['2']).to eq('75000')
|
||||||
|
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('125000')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when digest already exists' do
|
||||||
|
let!(:existing_digest) do
|
||||||
|
create(:users_digest, user: user, year: 2024, period_type: :yearly, distance: 10_000)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the existing digest' do
|
||||||
|
expect { calculate_digest }.not_to(change { Users::Digest.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
|
||||||
104
spec/services/users/digests/first_time_visits_calculator_spec.rb
Normal file
104
spec/services/users/digests/first_time_visits_calculator_spec.rb
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digests::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
|
||||||
116
spec/services/users/digests/year_over_year_calculator_spec.rb
Normal file
116
spec/services/users/digests/year_over_year_calculator_spec.rb
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::Digests::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
|
||||||
|
|
@ -25,12 +25,12 @@ RSpec.describe Users::SafeSettings do
|
||||||
immich_api_key: nil,
|
immich_api_key: nil,
|
||||||
photoprism_url: nil,
|
photoprism_url: nil,
|
||||||
photoprism_api_key: nil,
|
photoprism_api_key: nil,
|
||||||
maps: { "distance_unit" => "km" },
|
maps: { 'distance_unit' => 'km' },
|
||||||
distance_unit: 'km',
|
distance_unit: 'km',
|
||||||
visits_suggestions_enabled: true,
|
visits_suggestions_enabled: true,
|
||||||
speed_color_scale: nil,
|
speed_color_scale: nil,
|
||||||
fog_of_war_threshold: nil,
|
fog_of_war_threshold: nil,
|
||||||
enabled_map_layers: ['Routes', 'Heatmap'],
|
enabled_map_layers: %w[Routes Heatmap],
|
||||||
maps_maplibre_style: 'light'
|
maps_maplibre_style: 'light'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -56,7 +56,7 @@ RSpec.describe Users::SafeSettings do
|
||||||
'photoprism_api_key' => 'photoprism-key',
|
'photoprism_api_key' => 'photoprism-key',
|
||||||
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
||||||
'visits_suggestions_enabled' => false,
|
'visits_suggestions_enabled' => false,
|
||||||
'enabled_map_layers' => ['Points', 'Routes', 'Areas', 'Photos']
|
'enabled_map_layers' => %w[Points Routes Areas Photos]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
let(:safe_settings) { described_class.new(settings) }
|
let(:safe_settings) { described_class.new(settings) }
|
||||||
|
|
@ -64,24 +64,25 @@ RSpec.describe Users::SafeSettings do
|
||||||
it 'returns custom configuration' do
|
it 'returns custom configuration' do
|
||||||
expect(safe_settings.settings).to eq(
|
expect(safe_settings.settings).to eq(
|
||||||
{
|
{
|
||||||
"fog_of_war_meters" => 100,
|
'fog_of_war_meters' => 100,
|
||||||
"meters_between_routes" => 1000,
|
'meters_between_routes' => 1000,
|
||||||
"preferred_map_layer" => "Satellite",
|
'preferred_map_layer' => 'Satellite',
|
||||||
"speed_colored_routes" => true,
|
'speed_colored_routes' => true,
|
||||||
"points_rendering_mode" => "simplified",
|
'points_rendering_mode' => 'simplified',
|
||||||
"minutes_between_routes" => 60,
|
'minutes_between_routes' => 60,
|
||||||
"time_threshold_minutes" => 45,
|
'time_threshold_minutes' => 45,
|
||||||
"merge_threshold_minutes" => 20,
|
'merge_threshold_minutes' => 20,
|
||||||
"live_map_enabled" => false,
|
'live_map_enabled' => false,
|
||||||
"route_opacity" => 80,
|
'route_opacity' => 80,
|
||||||
"immich_url" => "https://immich.example.com",
|
'immich_url' => 'https://immich.example.com',
|
||||||
"immich_api_key" => "immich-key",
|
'immich_api_key' => 'immich-key',
|
||||||
"photoprism_url" => "https://photoprism.example.com",
|
'photoprism_url' => 'https://photoprism.example.com',
|
||||||
"photoprism_api_key" => "photoprism-key",
|
'photoprism_api_key' => 'photoprism-key',
|
||||||
"maps" => { "name" => "custom", "url" => "https://custom.example.com" },
|
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
||||||
"visits_suggestions_enabled" => false,
|
'visits_suggestions_enabled' => false,
|
||||||
"enabled_map_layers" => ['Points', 'Routes', 'Areas', 'Photos'],
|
'enabled_map_layers' => %w[Points Routes Areas Photos],
|
||||||
"maps_maplibre_style" => "light"
|
'maps_maplibre_style' => 'light',
|
||||||
|
'digest_emails_enabled' => true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -91,24 +92,24 @@ RSpec.describe Users::SafeSettings do
|
||||||
{
|
{
|
||||||
fog_of_war_meters: 100,
|
fog_of_war_meters: 100,
|
||||||
meters_between_routes: 1000,
|
meters_between_routes: 1000,
|
||||||
preferred_map_layer: "Satellite",
|
preferred_map_layer: 'Satellite',
|
||||||
speed_colored_routes: true,
|
speed_colored_routes: true,
|
||||||
points_rendering_mode: "simplified",
|
points_rendering_mode: 'simplified',
|
||||||
minutes_between_routes: 60,
|
minutes_between_routes: 60,
|
||||||
time_threshold_minutes: 45,
|
time_threshold_minutes: 45,
|
||||||
merge_threshold_minutes: 20,
|
merge_threshold_minutes: 20,
|
||||||
live_map_enabled: false,
|
live_map_enabled: false,
|
||||||
route_opacity: 80,
|
route_opacity: 80,
|
||||||
immich_url: "https://immich.example.com",
|
immich_url: 'https://immich.example.com',
|
||||||
immich_api_key: "immich-key",
|
immich_api_key: 'immich-key',
|
||||||
photoprism_url: "https://photoprism.example.com",
|
photoprism_url: 'https://photoprism.example.com',
|
||||||
photoprism_api_key: "photoprism-key",
|
photoprism_api_key: 'photoprism-key',
|
||||||
maps: { "name" => "custom", "url" => "https://custom.example.com" },
|
maps: { 'name' => 'custom', 'url' => 'https://custom.example.com' },
|
||||||
distance_unit: nil,
|
distance_unit: nil,
|
||||||
visits_suggestions_enabled: false,
|
visits_suggestions_enabled: false,
|
||||||
speed_color_scale: nil,
|
speed_color_scale: nil,
|
||||||
fog_of_war_threshold: nil,
|
fog_of_war_threshold: nil,
|
||||||
enabled_map_layers: ['Points', 'Routes', 'Areas', 'Photos'],
|
enabled_map_layers: %w[Points Routes Areas Photos],
|
||||||
maps_maplibre_style: 'light'
|
maps_maplibre_style: 'light'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -137,9 +138,9 @@ RSpec.describe Users::SafeSettings do
|
||||||
expect(safe_settings.immich_api_key).to be_nil
|
expect(safe_settings.immich_api_key).to be_nil
|
||||||
expect(safe_settings.photoprism_url).to be_nil
|
expect(safe_settings.photoprism_url).to be_nil
|
||||||
expect(safe_settings.photoprism_api_key).to be_nil
|
expect(safe_settings.photoprism_api_key).to be_nil
|
||||||
expect(safe_settings.maps).to eq({ "distance_unit" => "km" })
|
expect(safe_settings.maps).to eq({ 'distance_unit' => 'km' })
|
||||||
expect(safe_settings.visits_suggestions_enabled?).to be true
|
expect(safe_settings.visits_suggestions_enabled?).to be true
|
||||||
expect(safe_settings.enabled_map_layers).to eq(['Routes', 'Heatmap'])
|
expect(safe_settings.enabled_map_layers).to eq(%w[Routes Heatmap])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue