Compare commits

...

10 commits

Author SHA1 Message Date
Eugene Burmakin
df26984777 Allow sharing digest for 1 week or 1 month 2025-12-27 23:03:31 +01:00
Eugene Burmakin
230e641808 Update digest email setting handling 2025-12-27 21:24:19 +01:00
Eugene Burmakin
251ac8d2e7 Update CHANGELOG.md 2025-12-27 21:10:45 +01:00
Eugene Burmakin
7e8c881ac2 Remove cron job for yearly digest scheduling 2025-12-27 21:08:23 +01:00
Eugene Burmakin
75b5cbd9bd Fix layout of stats in yearly digest view 2025-12-27 21:07:49 +01:00
Eugene Burmakin
c19fbe14e6 Update colors 2025-12-27 20:52:25 +01:00
Eugene Burmakin
d7016e57b4 Add flags and chart to email 2025-12-27 20:41:35 +01:00
Eugene Burmakin
da9e440cfa Update yearly digest layout and styles 2025-12-27 20:12:35 +01:00
Eugene Burmakin
c12709ac15 Minor changes 2025-12-27 19:28:13 +01:00
Eugene Burmakin
c25bb6f4d4 Rename YearlyDigests to Users::Digests 2025-12-27 19:07:57 +01:00
52 changed files with 772 additions and 826 deletions

View file

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

View file

@ -18,7 +18,6 @@ gem 'foreman'
gem 'geocoder', github: 'Freika/geocoder', branch: 'master' gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
gem 'gpx' gem 'gpx'
gem 'groupdate' gem 'groupdate'
gem 'grover'
gem 'h3', '~> 3.7' gem 'h3', '~> 3.7'
gem 'httparty' gem 'httparty'
gem 'importmap-rails' gem 'importmap-rails'

View file

@ -204,8 +204,6 @@ GEM
rake rake
groupdate (6.7.0) groupdate (6.7.0)
activesupport (>= 7.1) activesupport (>= 7.1)
grover (1.2.4)
nokogiri (~> 1)
h3 (3.7.4) h3 (3.7.4)
ffi (~> 1.9) ffi (~> 1.9)
rgeo-geojson (~> 2.1) rgeo-geojson (~> 2.1)
@ -659,7 +657,6 @@ DEPENDENCIES
geocoder! geocoder!
gpx gpx
groupdate groupdate
grover
h3 (~> 3.7) h3 (~> 3.7)
httparty httparty
importmap-rails importmap-rails

File diff suppressed because one or more lines are too long

View 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

View 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

View file

@ -1,13 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
class Shared::YearlyDigestsController < ApplicationController class Shared::DigestsController < ApplicationController
helper YearlyDigestsHelper helper Users::DigestsHelper
helper CountryFlagHelper
before_action :authenticate_user!, except: [:show] before_action :authenticate_user!, except: [:show]
before_action :authenticate_active_user!, only: [:update] before_action :authenticate_active_user!, only: [:update]
def show def show
@digest = YearlyDigest.find_by(sharing_uuid: params[:uuid]) @digest = Users::Digest.find_by(sharing_uuid: params[:uuid])
unless @digest&.public_accessible? unless @digest&.public_accessible?
return redirect_to root_path, return redirect_to root_path,
@ -19,18 +20,18 @@ class Shared::YearlyDigestsController < ApplicationController
@distance_unit = @user.safe_settings.distance_unit || 'km' @distance_unit = @user.safe_settings.distance_unit || 'km'
@is_public_view = true @is_public_view = true
render 'yearly_digests/public_year' render 'users/digests/public_year'
end end
def update def update
@year = params[:year].to_i @year = params[:year].to_i
@digest = current_user.yearly_digests.yearly.find_by(year: @year) @digest = current_user.digests.yearly.find_by(year: @year)
return head :not_found unless @digest return head :not_found unless @digest
if params[:enabled] == '1' if params[:enabled] == '1'
@digest.enable_sharing!(expiration: params[:expiration] || '24h') @digest.enable_sharing!(expiration: params[:expiration] || '24h')
sharing_url = shared_yearly_digest_url(@digest.sharing_uuid) sharing_url = shared_users_digest_url(@digest.sharing_uuid)
render json: { render json: {
success: true, success: true,

View file

@ -1,14 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class YearlyDigestsController < ApplicationController class Users::DigestsController < ApplicationController
helper YearlyDigestsHelper helper Users::DigestsHelper
helper CountryFlagHelper
before_action :authenticate_user! before_action :authenticate_user!
before_action :authenticate_active_user!, only: [:create] before_action :authenticate_active_user!, only: [:create]
before_action :set_digest, only: [:show] before_action :set_digest, only: [:show]
def index def index
@digests = current_user.yearly_digests.yearly.order(year: :desc) @digests = current_user.digests.yearly.order(year: :desc)
@available_years = available_years_for_generation @available_years = available_years_for_generation
end end
@ -20,26 +21,26 @@ class YearlyDigestsController < ApplicationController
year = params[:year].to_i year = params[:year].to_i
if valid_year?(year) if valid_year?(year)
YearlyDigests::CalculatingJob.perform_later(current_user.id, year) Users::Digests::CalculatingJob.perform_later(current_user.id, year)
redirect_to yearly_digests_path, redirect_to users_digests_path,
notice: "Year-end digest for #{year} is being generated. Check back soon!", notice: "Year-end digest for #{year} is being generated. Check back soon!",
status: :see_other status: :see_other
else else
redirect_to yearly_digests_path, alert: 'Invalid year selected', status: :see_other redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
end end
end end
private private
def set_digest def set_digest
@digest = current_user.yearly_digests.yearly.find_by!(year: params[:year]) @digest = current_user.digests.yearly.find_by!(year: params[:year])
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to yearly_digests_path, alert: 'Digest not found' redirect_to users_digests_path, alert: 'Digest not found'
end end
def available_years_for_generation def available_years_for_generation
tracked_years = current_user.stats.select(:year).distinct.pluck(:year) tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
existing_digests = current_user.yearly_digests.yearly.pluck(:year) existing_digests = current_user.digests.yearly.pluck(:year)
(tracked_years - existing_digests).sort.reverse (tracked_years - existing_digests).sort.reverse
end end

View 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

View file

@ -1,51 +0,0 @@
# frozen_string_literal: true
module YearlyDigestsHelper
EARTH_CIRCUMFERENCE_KM = 40_075
MOON_DISTANCE_KM = 384_400
def distance_with_unit(distance_meters, unit)
value = YearlyDigest.convert_distance(distance_meters, unit).round
"#{number_with_delimiter(value)} #{unit}"
end
def distance_comparison_text(distance_meters)
distance_km = distance_meters.to_f / 1000
if distance_km >= MOON_DISTANCE_KM
percentage = ((distance_km / MOON_DISTANCE_KM) * 100).round(1)
"That's #{percentage}% of the distance to the Moon!"
else
percentage = ((distance_km / EARTH_CIRCUMFERENCE_KM) * 100).round(1)
"That's #{percentage}% of Earth's circumference!"
end
end
def format_time_spent(minutes)
return "#{minutes} minutes" if minutes < 60
hours = minutes / 60
remaining_minutes = minutes % 60
if hours < 24
"#{hours}h #{remaining_minutes}m"
else
days = hours / 24
remaining_hours = hours % 24
"#{days}d #{remaining_hours}h"
end
end
def yoy_change_class(change)
return '' if change.nil?
change.negative? ? 'negative' : 'positive'
end
def yoy_change_text(change)
return '' if change.nil?
prefix = change.positive? ? '+' : ''
"#{prefix}#{change}%"
end
end

View file

@ -1,10 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class YearlyDigests::CalculatingJob < ApplicationJob class Users::Digests::CalculatingJob < ApplicationJob
queue_as :digests queue_as :digests
def perform(user_id, year) def perform(user_id, year)
YearlyDigests::CalculateYear.new(user_id, year).call Users::Digests::CalculateYear.new(user_id, year).call
rescue StandardError => e rescue StandardError => e
create_digest_failed_notification(user_id, e) create_digest_failed_notification(user_id, e)
end end
@ -21,6 +21,6 @@ class YearlyDigests::CalculatingJob < ApplicationJob
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}" content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call ).call
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
# User was deleted, nothing to notify nil
end end
end end

View file

@ -1,20 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class YearlyDigests::EmailSendingJob < ApplicationJob class Users::Digests::EmailSendingJob < ApplicationJob
queue_as :mailers queue_as :mailers
def perform(user_id, year) def perform(user_id, year)
user = User.find(user_id) user = User.find(user_id)
digest = user.yearly_digests.yearly.find_by(year: year) digest = user.digests.yearly.find_by(year: year)
return unless should_send_email?(user, digest) return unless should_send_email?(user, digest)
YearlyDigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later Users::DigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later
digest.update!(sent_at: Time.current) digest.update!(sent_at: Time.current)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
ExceptionReporter.call( ExceptionReporter.call(
'YearlyDigests::EmailSendingJob', 'Users::Digests::EmailSendingJob',
"User with ID #{user_id} not found. Skipping year-end digest email." "User with ID #{user_id} not found. Skipping year-end digest email."
) )
end end
@ -23,8 +23,8 @@ class YearlyDigests::EmailSendingJob < ApplicationJob
def should_send_email?(user, digest) def should_send_email?(user, digest)
return false unless user.safe_settings.digest_emails_enabled? return false unless user.safe_settings.digest_emails_enabled?
return false unless digest.present? return false if digest.blank?
return false if digest.sent_at.present? # Already sent return false if digest.sent_at.present?
true true
end end

View file

@ -1,20 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class YearlyDigests::YearEndSchedulingJob < ApplicationJob class Users::Digests::YearEndSchedulingJob < ApplicationJob
queue_as :digests queue_as :digests
def perform def perform
year = Time.current.year - 1 # Previous year's digest year = Time.current.year - 1 # Previous year's digest
User.active_or_trial.find_each do |user| ::User.active_or_trial.find_each do |user|
# Skip if user has no data for the year # Skip if user has no data for the year
next unless user.stats.where(year: year).exists? next unless user.stats.where(year: year).exists?
# Schedule calculation first # Schedule calculation first
YearlyDigests::CalculatingJob.perform_later(user.id, year) Users::Digests::CalculatingJob.perform_later(user.id, year)
# Schedule email with delay to allow calculation to complete # Schedule email with delay to allow calculation to complete
YearlyDigests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year) Users::Digests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year)
end end
end end
end end

View 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

View file

@ -1,36 +0,0 @@
# frozen_string_literal: true
class YearlyDigestsMailer < ApplicationMailer
helper YearlyDigestsHelper
def year_end_digest
@user = params[:user]
@digest = params[:digest]
@distance_unit = @user.safe_settings.distance_unit || 'km'
# Generate chart image
@chart_image_name = generate_chart_attachment
mail(
to: @user.email,
subject: "Your #{@digest.year} Year in Review - Dawarich"
)
end
private
def generate_chart_attachment
image_data = YearlyDigests::ChartImageGenerator.new(@digest, distance_unit: @distance_unit).call
filename = 'monthly_distance_chart.png'
attachments.inline[filename] = {
mime_type: 'image/png',
content: image_data
}
filename
rescue StandardError => e
Rails.logger.error("Failed to generate chart image: #{e.message}")
nil
end
end

View file

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

View file

@ -21,7 +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 :yearly_digests, 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? }

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class YearlyDigest < ApplicationRecord class Users::Digest < ApplicationRecord
self.table_name = 'digests' self.table_name = 'digests'
include DistanceConvertible include DistanceConvertible
@ -10,15 +10,13 @@ class YearlyDigest < ApplicationRecord
belongs_to :user belongs_to :user
validates :year, presence: true validates :year, :period_type, presence: true
validates :period_type, presence: true
validates :year, uniqueness: { scope: %i[user_id period_type] } validates :year, uniqueness: { scope: %i[user_id period_type] }
before_create :generate_sharing_uuid before_create :generate_sharing_uuid
enum :period_type, { monthly: 0, yearly: 1 } enum :period_type, { monthly: 0, yearly: 1 }
# Sharing methods (following Stat model pattern)
def sharing_enabled? def sharing_enabled?
sharing_settings.try(:[], 'enabled') == true sharing_settings.try(:[], 'enabled') == true
end end
@ -48,12 +46,14 @@ class YearlyDigest < ApplicationRecord
end end
def enable_sharing!(expiration: '24h') def enable_sharing!(expiration: '24h')
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!(
@ -76,8 +76,6 @@ class YearlyDigest < ApplicationRecord
) )
end end
# Helper methods for accessing digest data
# toponyms is an array like: [{'country' => 'Germany', 'cities' => [{'city' => 'Berlin'}]}]
def countries_count def countries_count
return 0 unless toponyms.is_a?(Array) return 0 unless toponyms.is_a?(Array)
@ -131,7 +129,7 @@ class YearlyDigest < ApplicationRecord
end end
def total_distance_all_time def total_distance_all_time
all_time_stats['total_distance'] || 0 (all_time_stats['total_distance'] || 0).to_i
end end
def distance_km def distance_km

View 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

View 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

View 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

View file

@ -20,7 +20,7 @@ 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 'digest_emails_enabled' => true
}.freeze }.freeze
@ -142,6 +142,9 @@ class Users::SafeSettings
end end
def digest_emails_enabled? def digest_emails_enabled?
settings['digest_emails_enabled'] != false value = settings['digest_emails_enabled']
return true if value.nil?
ActiveModel::Type::Boolean.new.cast(value)
end end
end end

View file

@ -1,131 +0,0 @@
# frozen_string_literal: true
module YearlyDigests
class CalculateYear
def initialize(user_id, year)
@user = User.find(user_id)
@year = year.to_i
end
def call
return nil if monthly_stats.empty?
digest = YearlyDigest.find_or_initialize_by(user: user, year: year, period_type: :yearly)
digest.assign_attributes(
distance: total_distance,
toponyms: aggregate_toponyms,
monthly_distances: build_monthly_distances,
time_spent_by_location: calculate_time_spent,
first_time_visits: calculate_first_time_visits,
year_over_year: calculate_yoy_comparison,
all_time_stats: calculate_all_time_stats
)
digest.save!
digest
end
private
attr_reader :user, :year
def monthly_stats
@monthly_stats ||= user.stats.where(year: year).order(:month)
end
def total_distance
monthly_stats.sum(:distance)
end
def aggregate_toponyms
countries = []
cities = []
monthly_stats.each do |stat|
toponyms = stat.toponyms
next unless toponyms.is_a?(Array)
toponyms.each do |toponym|
next unless toponym.is_a?(Hash)
countries << toponym['country'] if toponym['country'].present?
next unless toponym['cities'].is_a?(Array)
toponym['cities'].each do |city|
cities << city['city'] if city.is_a?(Hash) && city['city'].present?
end
end
end
{
'countries' => countries.uniq.compact.sort,
'cities' => cities.uniq.compact.sort
}
end
def build_monthly_distances
result = {}
monthly_stats.each do |stat|
result[stat.month.to_s] = stat.distance
end
# Fill in missing months with 0
(1..12).each do |month|
result[month.to_s] ||= 0
end
result
end
def calculate_time_spent
country_time = Hash.new(0)
city_time = Hash.new(0)
monthly_stats.each do |stat|
toponyms = stat.toponyms
next unless toponyms.is_a?(Array)
toponyms.each do |toponym|
next unless toponym.is_a?(Hash)
country = toponym['country']
next unless toponym['cities'].is_a?(Array)
toponym['cities'].each do |city|
next unless city.is_a?(Hash)
stayed_for = city['stayed_for'].to_i
city_name = city['city']
country_time[country] += stayed_for if country.present?
city_time[city_name] += stayed_for if city_name.present?
end
end
end
{
'countries' => country_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } },
'cities' => city_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } }
}
end
def calculate_first_time_visits
FirstTimeVisitsCalculator.new(user, year).call
end
def calculate_yoy_comparison
YearOverYearCalculator.new(user, year).call
end
def calculate_all_time_stats
{
'total_countries' => user.countries_visited.count,
'total_cities' => user.cities_visited.count,
'total_distance' => user.stats.sum(:distance)
}
end
end
end

View file

@ -1,40 +0,0 @@
# frozen_string_literal: true
module YearlyDigests
class ChartImageGenerator
def initialize(digest, distance_unit: 'km')
@digest = digest
@distance_unit = distance_unit
end
def call
html = render_chart_html
generate_image(html)
end
private
attr_reader :digest, :distance_unit
def render_chart_html
ApplicationController.render(
template: 'yearly_digests/chart',
layout: false,
assigns: {
monthly_distances: digest.monthly_distances,
distance_unit: distance_unit
}
)
end
def generate_image(html)
grover = Grover.new(
html,
format: 'png',
viewport: { width: 600, height: 320 },
wait_until: 'networkidle0'
)
grover.to_png
end
end
end

View file

@ -1,75 +0,0 @@
# frozen_string_literal: true
module YearlyDigests
class FirstTimeVisitsCalculator
def initialize(user, year)
@user = user
@year = year.to_i
end
def call
{
'countries' => first_time_countries,
'cities' => first_time_cities
}
end
private
attr_reader :user, :year
def previous_years_stats
@previous_years_stats ||= user.stats.where('year < ?', year)
end
def current_year_stats
@current_year_stats ||= user.stats.where(year: year)
end
def previous_countries
@previous_countries ||= extract_countries(previous_years_stats)
end
def previous_cities
@previous_cities ||= extract_cities(previous_years_stats)
end
def current_countries
@current_countries ||= extract_countries(current_year_stats)
end
def current_cities
@current_cities ||= extract_cities(current_year_stats)
end
def first_time_countries
(current_countries - previous_countries).sort
end
def first_time_cities
(current_cities - previous_cities).sort
end
def extract_countries(stats)
stats.flat_map do |stat|
toponyms = stat.toponyms
next [] unless toponyms.is_a?(Array)
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) }
end.uniq.compact
end
def extract_cities(stats)
stats.flat_map do |stat|
toponyms = stat.toponyms
next [] unless toponyms.is_a?(Array)
toponyms.flat_map do |t|
next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array)
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) }
end
end.uniq.compact
end
end
end

View file

@ -1,77 +0,0 @@
# frozen_string_literal: true
module YearlyDigests
class YearOverYearCalculator
def initialize(user, year)
@user = user
@year = year.to_i
end
def call
return {} unless previous_year_stats.exists?
{
'previous_year' => year - 1,
'distance_change_percent' => calculate_distance_change_percent,
'countries_change' => calculate_countries_change,
'cities_change' => calculate_cities_change
}.compact
end
private
attr_reader :user, :year
def previous_year_stats
@previous_year_stats ||= user.stats.where(year: year - 1)
end
def current_year_stats
@current_year_stats ||= user.stats.where(year: year)
end
def calculate_distance_change_percent
prev_distance = previous_year_stats.sum(:distance)
return nil if prev_distance.zero?
curr_distance = current_year_stats.sum(:distance)
((curr_distance - prev_distance).to_f / prev_distance * 100).round
end
def calculate_countries_change
prev_count = count_countries(previous_year_stats)
curr_count = count_countries(current_year_stats)
curr_count - prev_count
end
def calculate_cities_change
prev_count = count_cities(previous_year_stats)
curr_count = count_cities(current_year_stats)
curr_count - prev_count
end
def count_countries(stats)
stats.flat_map do |stat|
toponyms = stat.toponyms
next [] unless toponyms.is_a?(Array)
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) }
end.uniq.compact.count
end
def count_cities(stats)
stats.flat_map do |stat|
toponyms = stat.toponyms
next [] unless toponyms.is_a?(Array)
toponyms.flat_map do |t|
next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array)
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) }
end
end.uniq.compact.count
end
end
end

View file

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

View file

@ -3,7 +3,7 @@
<div class="w-full my-5"> <div class="w-full my-5">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Statistics</h1> <h1 class="text-3xl font-bold">Statistics</h1>
<%= link_to yearly_digests_path, class: 'btn btn-outline btn-sm' do %> <%= link_to users_digests_path, class: 'btn btn-outline btn-sm' do %>
<%= icon 'earth' %> Year-End Digests <%= icon 'earth' %> Year-End Digests
<% end %> <% end %>
</div> </div>

View file

@ -1,7 +1,7 @@
<% content_for :title, 'Year-End Digests' %> <% content_for :title, 'Year-End Digests' %>
<div class="w-full my-5"> <div class="max-w-screen-2xl mx-auto my-5 px-4">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6 gap-8">
<h1 class="text-3xl font-bold flex items-center gap-2"> <h1 class="text-3xl font-bold flex items-center gap-2">
<%= icon 'earth' %> Year-End Digests <%= icon 'earth' %> Year-End Digests
</h1> </h1>
@ -9,12 +9,12 @@
<% if @available_years.any? && current_user.active? %> <% if @available_years.any? && current_user.active? %>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-primary"> <label tabindex="0" class="btn btn-primary">
<%= icon 'plus' %> Generate Digest <%= icon 'calendar-plus-2' %> Generate Digest
</label> </label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<% @available_years.each do |year| %> <% @available_years.each do |year| %>
<li> <li>
<%= link_to year, yearly_digests_path(year: year), <%= link_to year, users_digests_path(year: year),
data: { turbo_method: :post }, data: { turbo_method: :post },
class: 'text-base' %> class: 'text-base' %>
</li> </li>
@ -27,8 +27,9 @@
<% if @digests.empty? %> <% if @digests.empty? %>
<div class="card bg-base-200 shadow-xl"> <div class="card bg-base-200 shadow-xl">
<div class="card-body text-center py-12"> <div class="card-body text-center py-12">
<div class="text-6xl mb-4"><%= icon 'earth' %></div> <h2 class="text-xl font-semibold mb-2 flex items-center justify-center gap-2">
<h2 class="text-xl font-semibold mb-2">No Year-End Digests Yet</h2> <%= icon 'earth' %>No Year-End Digests Yet
</h2>
<p class="text-gray-500 mb-4"> <p class="text-gray-500 mb-4">
Year-end digests are automatically generated on January 1st each year. Year-end digests are automatically generated on January 1st each year.
<% if @available_years.any? && current_user.active? %> <% if @available_years.any? && current_user.active? %>
@ -38,18 +39,18 @@
</div> </div>
</div> </div>
<% else %> <% else %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 gap-6">
<% @digests.each do |digest| %> <% @digests.each do |digest| %>
<div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow"> <div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl justify-between"> <h2 class="card-title text-2xl justify-between">
<%= link_to digest.year, yearly_digest_path(year: digest.year), class: 'hover:text-primary' %> <%= link_to digest.year, users_digest_path(year: digest.year), class: 'hover:text-primary' %>
<% if digest.sharing_enabled? %> <% if digest.sharing_enabled? %>
<span class="badge badge-success badge-sm">Shared</span> <span class="badge badge-success badge-sm">Shared</span>
<% end %> <% end %>
</h2> </h2>
<div class="stats stats-vertical shadow bg-base-100 mt-4"> <div class="stats stats-vertical shadow bg-base-100 mt-4 text-center">
<div class="stat"> <div class="stat">
<div class="stat-title">Distance</div> <div class="stat-title">Distance</div>
<div class="stat-value text-primary text-lg"> <div class="stat-value text-primary text-lg">
@ -58,20 +59,20 @@
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Countries</div>
<div class="stat-value text-secondary text-lg"><%= digest.countries_count %></div> <div class="stat-value text-secondary text-lg"><%= digest.countries_count %></div>
<div class="stat-title">Countries</div>
<% if digest.first_time_countries.any? %> <% if digest.first_time_countries.any? %>
<div class="stat-desc text-success"> <div class="stat-desc text-success flex items-center gap-1 justify-center">
<%= icon 'star' %> <%= digest.first_time_countries.count %> new <%= icon 'star' %> <%= digest.first_time_countries.count %> new
</div> </div>
<% end %> <% end %>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Cities</div>
<div class="stat-value text-accent text-lg"><%= digest.cities_count %></div> <div class="stat-value text-accent text-lg"><%= digest.cities_count %></div>
<div class="stat-title">Cities</div>
<% if digest.first_time_cities.any? %> <% if digest.first_time_cities.any? %>
<div class="stat-desc text-success"> <div class="stat-desc text-success flex items-center gap-1 justify-center">
<%= icon 'star' %> <%= digest.first_time_cities.count %> new <%= icon 'star' %> <%= digest.first_time_cities.count %> new
</div> </div>
<% end %> <% end %>
@ -79,7 +80,7 @@
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<%= link_to yearly_digest_path(year: digest.year), class: 'btn btn-primary btn-sm' do %> <%= link_to users_digest_path(year: digest.year), class: 'btn btn-primary btn-sm' do %>
View Details View Details
<% end %> <% end %>
</div> </div>

View file

@ -1,6 +1,6 @@
<div class="container mx-auto px-4 py-8"> <div class="max-w-xl mx-auto px-4 py-8">
<!-- Header --> <!-- Header -->
<div class="hero bg-gradient-to-br from-blue-600 to-purple-700 text-white rounded-lg shadow-lg mb-8"> <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="hero-content text-center py-12">
<div class="max-w-lg"> <div class="max-w-lg">
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1> <h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
@ -20,24 +20,24 @@
<div class="stat place-items-center text-center"> <div class="stat place-items-center text-center">
<div class="stat-title">Countries visited</div> <div class="stat-title">Countries visited</div>
<div class="stat-value text-secondary"><%= @digest.countries_count %></div> <div class="stat-value text-secondary"><%= @digest.countries_count %></div>
<% if @digest.first_time_countries.any? %> <div class="stat-desc <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
<div class="stat-desc text-success"><%= @digest.first_time_countries.count %> first time</div> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
<% end %> </div>
</div> </div>
<div class="stat place-items-center text-center"> <div class="stat place-items-center text-center">
<div class="stat-title">Cities explored</div> <div class="stat-title">Cities explored</div>
<div class="stat-value text-accent"><%= @digest.cities_count %></div> <div class="stat-value text-accent"><%= @digest.cities_count %></div>
<% if @digest.first_time_cities.any? %> <div class="stat-desc <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
<div class="stat-desc text-success"><%= @digest.first_time_cities.count %> first time</div> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
<% end %> </div>
</div> </div>
</div> </div>
<!-- First Time Visits --> <!-- First Time Visits -->
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %> <% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
<div class="card bg-base-100 shadow-xl mb-8"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'star' %> First Time Visits <%= icon 'star' %> First Time Visits
</h2> </h2>
@ -45,7 +45,7 @@
<% if @digest.first_time_countries.any? %> <% if @digest.first_time_countries.any? %>
<div class="mb-4"> <div class="mb-4">
<h3 class="font-semibold mb-2">New Countries</h3> <h3 class="font-semibold mb-2">New Countries</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_countries.each do |country| %> <% @digest.first_time_countries.each do |country| %>
<span class="badge badge-success badge-lg"><%= country %></span> <span class="badge badge-success badge-lg"><%= country %></span>
<% end %> <% end %>
@ -56,7 +56,7 @@
<% if @digest.first_time_cities.any? %> <% if @digest.first_time_cities.any? %>
<div> <div>
<h3 class="font-semibold mb-2">New Cities</h3> <h3 class="font-semibold mb-2">New Cities</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_cities.take(5).each do |city| %> <% @digest.first_time_cities.take(5).each do |city| %>
<span class="badge badge-outline"><%= city %></span> <span class="badge badge-outline"><%= city %></span>
<% end %> <% end %>
@ -73,14 +73,14 @@
<!-- Monthly Distance Chart --> <!-- Monthly Distance Chart -->
<% if @digest.monthly_distances.present? %> <% if @digest.monthly_distances.present? %>
<div class="card bg-base-100 shadow-xl mb-8"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'activity' %> Year by Month <%= icon 'activity' %> Year by Month
</h2> </h2>
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative"> <div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
<%= column_chart( <%= column_chart(
@digest.monthly_distances.sort.map { |month, distance_meters| @digest.monthly_distances.sort.map { |month, distance_meters|
[Date::ABBR_MONTHNAMES[month.to_i], YearlyDigest.convert_distance(distance_meters, @distance_unit).round] [Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @distance_unit).round]
}, },
height: '200px', height: '200px',
suffix: " #{@distance_unit}", suffix: " #{@distance_unit}",
@ -101,14 +101,17 @@
<!-- Top Countries by Time Spent --> <!-- Top Countries by Time Spent -->
<% if @digest.top_countries_by_time.any? %> <% if @digest.top_countries_by_time.any? %>
<div class="card bg-base-100 shadow-xl mb-8"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'map-pin' %> Where They Spent the Most Time <%= icon 'map-pin' %> Where They Spent the Most Time
</h2> </h2>
<ul class="space-y-2"> <ul class="space-y-2 w-full">
<% @digest.top_countries_by_time.take(3).each do |country| %> <% @digest.top_countries_by_time.take(3).each do |country| %>
<li class="flex justify-between items-center p-3 bg-base-200 rounded-lg"> <li class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
<span class="font-semibold"><%= country['name'] %></span> <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> <span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
</li> </li>
<% end %> <% end %>
@ -119,15 +122,18 @@
<!-- Countries & Cities --> <!-- Countries & Cities -->
<div class="card bg-base-100 shadow-xl mb-8"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'earth' %> Countries & Cities <%= icon 'earth' %> Countries & Cities
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4 w-full">
<% @digest.toponyms&.each_with_index do |country, index| %> <% @digest.toponyms&.each_with_index do |country, index| %>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="font-semibold"><%= country['country'] %></span> <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> <span class="text-sm"><%= country['cities']&.length || 0 %> cities</span>
</div> </div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress> <progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress>
@ -137,7 +143,7 @@
<div class="divider"></div> <div class="divider"></div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2 justify-center w-full">
<span class="text-sm font-medium">Cities visited:</span> <span class="text-sm font-medium">Cities visited:</span>
<% @digest.toponyms&.each do |country| %> <% @digest.toponyms&.each do |country| %>
<% country['cities']&.take(5)&.each do |city| %> <% country['cities']&.take(5)&.each do |city| %>
@ -153,23 +159,23 @@
<!-- All-Time Stats --> <!-- All-Time Stats -->
<div class="card bg-slate-800 text-white shadow-xl mb-8"> <div class="card bg-slate-800 text-white shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title text-white"> <h2 class="card-title text-white">
<%= icon 'trophy' %> All-Time Stats <%= icon 'trophy' %> All-Time Stats
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4"> <div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat"> <div class="stat place-items-center">
<div class="stat-title text-gray-400">Countries visited</div> <div class="stat-title text-gray-400">Countries visited</div>
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div> <div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
</div> </div>
<div class="stat"> <div class="stat place-items-center">
<div class="stat-title text-gray-400">Cities explored</div> <div class="stat-title text-gray-400">Cities explored</div>
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div> <div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
</div> </div>
<div class="stat"> </div>
<div class="stat-title text-gray-400">Total distance</div> <div class="stat place-items-center mt-2">
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div> <div class="stat-title text-gray-400">Total distance</div>
</div> <div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
<% content_for :title, "#{@digest.year} Year in Review" %> <% content_for :title, "#{@digest.year} Year in Review" %>
<div class="w-full my-5"> <div class="max-w-xl mx-auto my-5">
<!-- Header --> <!-- Header -->
<div class="hero bg-gradient-to-br from-blue-600 to-purple-700 text-white rounded-lg shadow-lg mb-8"> <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="hero-content text-center py-12 relative w-full">
<div class="max-w-lg"> <div class="max-w-lg">
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1> <h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
@ -17,7 +17,7 @@
<!-- Distance Card --> <!-- Distance Card -->
<div class="card bg-base-200 shadow-xl mb-8"> <div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<div class="stat-title flex items-center gap-2"> <div class="stat-title flex items-center gap-2">
<%= icon 'map' %> Distance Traveled <%= icon 'map' %> Distance Traveled
</div> </div>
@ -40,11 +40,9 @@
<%= icon 'globe' %> Countries <%= icon 'globe' %> Countries
</div> </div>
<div class="stat-value text-secondary"><%= @digest.countries_count %></div> <div class="stat-value text-secondary"><%= @digest.countries_count %></div>
<% if @digest.first_time_countries.any? %> <div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
<div class="stat-desc text-success font-medium"> <%= icon 'star' %> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
<%= icon 'star' %> <%= @digest.first_time_countries.count %> first time </div>
</div>
<% end %>
</div> </div>
<div class="stat place-items-center"> <div class="stat place-items-center">
@ -52,18 +50,16 @@
<%= icon 'building' %> Cities <%= icon 'building' %> Cities
</div> </div>
<div class="stat-value text-accent"><%= @digest.cities_count %></div> <div class="stat-value text-accent"><%= @digest.cities_count %></div>
<% if @digest.first_time_cities.any? %> <div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
<div class="stat-desc text-success font-medium"> <%= icon 'star' %> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
<%= icon 'star' %> <%= @digest.first_time_cities.count %> first time </div>
</div>
<% end %>
</div> </div>
</div> </div>
<!-- First Time Visits --> <!-- First Time Visits -->
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %> <% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
<div class="card bg-base-200 shadow-xl mb-8"> <div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'star' %> First Time Visits <%= icon 'star' %> First Time Visits
</h2> </h2>
@ -71,7 +67,7 @@
<% if @digest.first_time_countries.any? %> <% if @digest.first_time_countries.any? %>
<div class="mb-4"> <div class="mb-4">
<h3 class="font-semibold mb-2">New Countries</h3> <h3 class="font-semibold mb-2">New Countries</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_countries.each do |country| %> <% @digest.first_time_countries.each do |country| %>
<span class="badge badge-success badge-lg"><%= country %></span> <span class="badge badge-success badge-lg"><%= country %></span>
<% end %> <% end %>
@ -82,7 +78,7 @@
<% if @digest.first_time_cities.any? %> <% if @digest.first_time_cities.any? %>
<div> <div>
<h3 class="font-semibold mb-2">New Cities</h3> <h3 class="font-semibold mb-2">New Cities</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_cities.take(10).each do |city| %> <% @digest.first_time_cities.take(10).each do |city| %>
<span class="badge badge-outline"><%= city %></span> <span class="badge badge-outline"><%= city %></span>
<% end %> <% end %>
@ -99,14 +95,14 @@
<!-- Monthly Distance Chart --> <!-- Monthly Distance Chart -->
<% if @digest.monthly_distances.present? %> <% if @digest.monthly_distances.present? %>
<div class="card bg-base-200 shadow-xl mb-8"> <div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'activity' %> Your Year, Month by Month <%= icon 'activity' %> Your Year, Month by Month
</h2> </h2>
<div class="w-full h-64 bg-base-100 rounded-lg p-4"> <div class="w-full h-64 bg-base-100 rounded-lg p-4">
<%= column_chart( <%= column_chart(
@digest.monthly_distances.sort.map { |month, distance_meters| @digest.monthly_distances.sort.map { |month, distance_meters|
[Date::ABBR_MONTHNAMES[month.to_i], YearlyDigest.convert_distance(distance_meters, @distance_unit).round] [Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @distance_unit).round]
}, },
height: '250px', height: '250px',
suffix: " #{@distance_unit}", suffix: " #{@distance_unit}",
@ -127,18 +123,21 @@
<!-- Top Countries by Time Spent --> <!-- Top Countries by Time Spent -->
<% if @digest.top_countries_by_time.any? %> <% if @digest.top_countries_by_time.any? %>
<div class="card bg-base-200 shadow-xl mb-8"> <div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'map-pin' %> Where You Spent the Most Time <%= icon 'map-pin' %> Where You Spent the Most Time
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4 w-full">
<% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %> <% @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 justify-between items-center p-3 bg-base-100 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="badge badge-lg <%= ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success'][index] %>"> <span class="badge badge-lg <%= ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success'][index] %>">
<%= index + 1 %> <%= index + 1 %>
</span> </span>
<span class="font-semibold"><%= country['name'] %></span> <span class="font-semibold">
<span class="mr-1"><%= country_flag(country['name']) %></span>
<%= country['name'] %>
</span>
</div> </div>
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span> <span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
</div> </div>
@ -150,11 +149,11 @@
<!-- All Countries & Cities --> <!-- All Countries & Cities -->
<div class="card bg-base-200 shadow-xl mb-8"> <div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title"> <h2 class="card-title">
<%= icon 'earth' %> Countries & Cities <%= icon 'earth' %> Countries & Cities
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4 w-full">
<% if @digest.toponyms.present? %> <% if @digest.toponyms.present? %>
<% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %> <% 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'] %> <% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
@ -166,7 +165,10 @@
<div class="space-y-2"> <div class="space-y-2">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="font-semibold"><%= country['country'] %></span> <span class="font-semibold">
<span class="mr-1"><%= country_flag(country['country']) %></span>
<%= country['country'] %>
</span>
<span class="text-sm"> <span class="text-sm">
<%= pluralize(cities_count, 'city') %> <%= pluralize(cities_count, 'city') %>
</span> </span>
@ -183,30 +185,30 @@
<!-- All-Time Stats Footer --> <!-- All-Time Stats Footer -->
<div class="card bg-slate-800 text-white shadow-xl mb-8"> <div class="card bg-slate-800 text-white shadow-xl mb-8">
<div class="card-body"> <div class="card-body text-center items-center">
<h2 class="card-title text-white"> <h2 class="card-title text-white">
<%= icon 'trophy' %> All-Time Stats <%= icon 'trophy' %> All-Time Stats
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4"> <div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat"> <div class="stat place-items-center">
<div class="stat-title text-gray-400">Countries visited</div> <div class="stat-title text-gray-400">Countries visited</div>
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div> <div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
</div> </div>
<div class="stat"> <div class="stat place-items-center">
<div class="stat-title text-gray-400">Cities explored</div> <div class="stat-title text-gray-400">Cities explored</div>
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div> <div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
</div> </div>
<div class="stat"> </div>
<div class="stat-title text-gray-400">Total distance</div> <div class="stat place-items-center mt-2">
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div> <div class="stat-title text-gray-400">Total distance</div>
</div> <div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-wrap gap-4 justify-center"> <div class="flex flex-wrap gap-4 justify-center">
<%= link_to yearly_digests_path, class: 'btn btn-outline' do %> <%= link_to users_digests_path, class: 'btn btn-outline' do %>
Back to All Digests Back to All Digests
<% end %> <% end %>
<button class="btn btn-outline" onclick="sharing_modal.showModal()"> <button class="btn btn-outline" onclick="sharing_modal.showModal()">
@ -227,7 +229,7 @@
</h3> </h3>
<div data-controller="sharing-modal" <div data-controller="sharing-modal"
data-sharing-modal-url-value="<%= sharing_yearly_digest_path(year: @digest.year) %>"> data-sharing-modal-url-value="<%= sharing_users_digest_path(year: @digest.year) %>">
<!-- Enable/Disable Sharing Toggle --> <!-- Enable/Disable Sharing Toggle -->
<div class="form-control mb-4"> <div class="form-control mb-4">
@ -260,8 +262,10 @@
<%= options_for_select([ <%= options_for_select([
['1 hour', '1h'], ['1 hour', '1h'],
['12 hours', '12h'], ['12 hours', '12h'],
['24 hours', '24h'] ['24 hours', '24h'],
], @digest&.sharing_settings&.dig('expiration') || '1h') %> ['1 week', '1w'],
['1 month', '1m']
], @digest&.sharing_settings&.dig('expiration') || '24h') %>
</select> </select>
</div> </div>
@ -275,7 +279,7 @@
readonly readonly
class="input input-bordered join-item flex-1" class="input input-bordered join-item flex-1"
data-sharing-modal-target="sharingLink" data-sharing-modal-target="sharingLink"
value="<%= @digest.sharing_enabled? ? shared_yearly_digest_url(@digest.sharing_uuid) : '' %>" /> value="<%= @digest.sharing_enabled? ? shared_users_digest_url(@digest.sharing_uuid) : '' %>" />
<button type="button" <button type="button"
class="btn btn-outline join-item" class="btn btn-outline join-item"
data-action="click->sharing-modal#copyLink"> data-action="click->sharing-modal#copyLink">

View file

@ -8,13 +8,13 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #333; color: #333;
max-width: 600px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
background-color: #f5f5f5; background-color: #f5f5f5;
} }
.header { .header {
background: linear-gradient(135deg, #2563eb, #7c3aed); background: linear-gradient(135deg, #0f766e, #0284c7);
color: white; color: white;
padding: 40px 30px; padding: 40px 30px;
text-align: center; text-align: center;
@ -40,6 +40,7 @@
padding: 20px; padding: 20px;
margin: 16px 0; margin: 16px 0;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
text-align: center;
} }
.stat-value { .stat-value {
font-size: 36px; font-size: 36px;
@ -113,6 +114,7 @@
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
margin: 20px 0; margin: 20px 0;
text-align: center;
} }
.all-time-footer h3 { .all-time-footer h3 {
color: white; color: white;
@ -120,8 +122,6 @@
font-size: 18px; font-size: 18px;
} }
.all-time-stat { .all-time-stat {
display: flex;
justify-content: space-between;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.1); border-bottom: 1px solid rgba(255,255,255,0.1);
} }
@ -130,9 +130,13 @@
} }
.all-time-stat .label { .all-time-stat .label {
opacity: 0.8; opacity: 0.8;
display: block;
font-size: 12px;
margin-bottom: 4px;
} }
.all-time-stat .value { .all-time-stat .value {
font-weight: 600; font-weight: 600;
font-size: 24px;
} }
.footer { .footer {
text-align: center; text-align: center;
@ -162,6 +166,12 @@
<p>Your journey, by the numbers</p> <p>Your journey, by the numbers</p>
</div> </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"> <div class="content">
<!-- Distance Traveled --> <!-- Distance Traveled -->
<div class="stat-card"> <div class="stat-card">
@ -204,10 +214,34 @@
</div> </div>
<!-- Monthly Distance Chart --> <!-- Monthly Distance Chart -->
<% if @chart_image_name %> <% if @digest.monthly_distances.present? %>
<div class="chart-container"> <div class="chart-container">
<h3 style="margin: 0 0 16px 0; color: #1e293b;">Your Year, Month by Month</h3> <h3 style="margin: 0 0 16px 0; color: #1e293b;">Your Year, Month by Month</h3>
<%= image_tag attachments[@chart_image_name].url, alt: 'Monthly Distance Chart' %> <% 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> </div>
<% end %> <% end %>
@ -218,7 +252,7 @@
<ul class="location-list"> <ul class="location-list">
<% @digest.top_countries_by_time.take(3).each do |country| %> <% @digest.top_countries_by_time.take(3).each do |country| %>
<li> <li>
<span><%= country['name'] %></span> <span><%= country_flag(country['name']) %> <%= country['name'] %></span>
<span><%= format_time_spent(country['minutes']) %></span> <span><%= format_time_spent(country['minutes']) %></span>
</li> </li>
<% end %> <% end %>
@ -229,21 +263,31 @@
<!-- All-Time Stats Footer --> <!-- All-Time Stats Footer -->
<div class="all-time-footer"> <div class="all-time-footer">
<h3>All-Time Stats</h3> <h3>All-Time Stats</h3>
<div class="all-time-stat"> <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 16px;">
<span class="label">Countries visited</span> <tr>
<span class="value"><%= @digest.total_countries_all_time %></span> <td width="50%" style="text-align: center; padding: 8px;">
</div> <div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Countries visited</div>
<div class="all-time-stat"> <div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_countries_all_time %></div>
<span class="label">Cities explored</span> </td>
<span class="value"><%= @digest.total_cities_all_time %></span> <td width="50%" style="text-align: center; padding: 8px;">
</div> <div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Cities explored</div>
<div class="all-time-stat"> <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="label">Total distance</span>
<span class="value"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></span> <span class="value"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></span>
</div> </div>
</div> </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"> <div class="footer">
<p>Powered by <a href="https://dawarich.app">Dawarich</a>, your personal location history.</p> <p>Powered by <a href="https://dawarich.app">Dawarich</a>, your personal location history.</p>
<p class="unsubscribe"> <p class="unsubscribe">

View file

@ -1,9 +1,7 @@
<%= @digest.year %> Year in Review <%= @digest.year %> Year in Review
==================================== ====================================
Hi <%= @user.email %>, 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 %>.
Here's your year in review!
DISTANCE TRAVELED DISTANCE TRAVELED
<%= distance_with_unit(@digest.distance, @distance_unit) %> <%= distance_with_unit(@digest.distance, @distance_unit) %>
@ -34,6 +32,8 @@ ALL-TIME STATS
- <%= @digest.total_cities_all_time %> cities explored - <%= @digest.total_cities_all_time %> cities explored
- <%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %> traveled - <%= 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 Powered by Dawarich
https://dawarich.app https://dawarich.app

View file

@ -1,75 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
background: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#chart-container {
width: 560px;
height: 280px;
}
</style>
</head>
<body>
<div id="chart-container">
<canvas id="monthlyChart"></canvas>
</div>
<script>
const ctx = document.getElementById('monthlyChart').getContext('2d');
const rawData = <%= @monthly_distances.to_json.html_safe %>;
// Convert to km and ensure all 12 months are present
const monthlyData = [];
for (let i = 1; i <= 12; i++) {
const meters = rawData[i.toString()] || 0;
monthlyData.push((meters / 1000).toFixed(1));
}
const monthColors = [
'#397bb5', '#5A4E9D', '#3B945E', '#7BC96F',
'#FFD54F', '#FFA94D', '#FF6B6B', '#FF8C42',
'#C97E4F', '#8B4513', '#5A2E2E', '#265d7d'
];
new Chart(ctx, {
type: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
datasets: [{
label: 'Distance (<%= @distance_unit %>)',
data: monthlyData,
backgroundColor: monthColors,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
title: {
display: true,
text: 'Your Year, Month by Month',
font: { size: 16, weight: 'bold' }
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '<%= @distance_unit %>'
}
}
}
}
});
</script>
</body>
</html>

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
Grover.configure do |config|
config.options = {
format: 'png',
quality: 90,
wait_until: 'networkidle0',
launch_args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
}
end

View file

@ -98,13 +98,15 @@ 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}/ }
# Yearly digests routes # User digests routes (yearly/monthly digest reports)
resources :yearly_digests, only: %i[index create], param: :year scope module: 'users' do
get 'yearly_digests/:year', to: 'yearly_digests#show', as: :yearly_digest, constraints: { year: /\d{4}/ } resources :digests, only: %i[index create], param: :year, as: :users_digests
get 'shared/year/:uuid', to: 'shared/yearly_digests#show', as: :shared_yearly_digest get 'digests/:year', to: 'digests#show', as: :users_digest, constraints: { year: /\d{4}/ }
patch 'yearly_digests/:year/sharing', end
to: 'shared/yearly_digests#update', get 'shared/digest/:uuid', to: 'shared/digests#show', as: :shared_users_digest
as: :sharing_yearly_digest, patch 'digests/:year/sharing',
to: 'shared/digests#update',
as: :sharing_users_digest,
constraints: { year: /\d{4}/ } constraints: { year: /\d{4}/ }
root to: 'home#index' root to: 'home#index'

View file

@ -49,8 +49,3 @@ nightly_family_invitations_cleanup_job:
cron: "30 2 * * *" # every day at 02:30 cron: "30 2 * * *" # every day at 02:30
class: "Family::Invitations::CleanupJob" class: "Family::Invitations::CleanupJob"
queue: family queue: family
year_end_digest_job:
cron: "0 0 1 1 *" # January 1st at 00:00
class: "YearlyDigests::YearEndSchedulingJob"
queue: digests

View file

@ -8,7 +8,7 @@ class CreateDigests < ActiveRecord::Migration[8.0]
t.integer :period_type, null: false, default: 0 # enum: monthly: 0, yearly: 1 t.integer :period_type, null: false, default: 0 # enum: monthly: 0, yearly: 1
# Aggregated data # Aggregated data
t.integer :distance, null: false, default: 0 # Total distance in meters t.bigint :distance, null: false, default: 0 # Total distance in meters
t.jsonb :toponyms, default: {} # Countries/cities data t.jsonb :toponyms, default: {} # Countries/cities data
t.jsonb :monthly_distances, default: {} # {1: meters, 2: meters, ...} t.jsonb :monthly_distances, default: {} # {1: meters, 2: meters, ...}
t.jsonb :time_spent_by_location, default: {} # Top locations by time t.jsonb :time_spent_by_location, default: {} # Top locations by time

View file

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

View file

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

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :yearly_digest do factory :users_digest, class: 'Users::Digest' do
year { 2024 } year { 2024 }
period_type { :yearly } period_type { :yearly }
distance { 500_000 } # 500 km distance { 500_000 } # 500 km
@ -28,18 +28,18 @@ FactoryBot.define do
monthly_distances do monthly_distances do
{ {
'1' => 50_000, '1' => '50000',
'2' => 45_000, '2' => '45000',
'3' => 60_000, '3' => '60000',
'4' => 55_000, '4' => '55000',
'5' => 40_000, '5' => '40000',
'6' => 35_000, '6' => '35000',
'7' => 30_000, '7' => '30000',
'8' => 45_000, '8' => '45000',
'9' => 50_000, '9' => '50000',
'10' => 40_000, '10' => '40000',
'11' => 25_000, '11' => '25000',
'12' => 25_000 '12' => '25000'
} }
end end
@ -78,7 +78,7 @@ FactoryBot.define do
{ {
'total_countries' => 10, 'total_countries' => 10,
'total_cities' => 45, 'total_cities' => 45,
'total_distance' => 2_500_000 'total_distance' => '2500000'
} }
end end

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigests::CalculatingJob, type: :job do RSpec.describe Users::Digests::CalculatingJob, type: :job do
describe '#perform' do describe '#perform' do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let(:year) { 2024 } let(:year) { 2024 }
@ -10,23 +10,23 @@ RSpec.describe YearlyDigests::CalculatingJob, type: :job do
subject { described_class.perform_now(user.id, year) } subject { described_class.perform_now(user.id, year) }
before do before do
allow(YearlyDigests::CalculateYear).to receive(:new).and_call_original allow(Users::Digests::CalculateYear).to receive(:new).and_call_original
allow_any_instance_of(YearlyDigests::CalculateYear).to receive(:call) allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call)
end end
it 'calls YearlyDigests::CalculateYear service' do it 'calls Users::Digests::CalculateYear service' do
subject subject
expect(YearlyDigests::CalculateYear).to have_received(:new).with(user.id, year) expect(Users::Digests::CalculateYear).to have_received(:new).with(user.id, year)
end end
it 'enqueues to the digests queue' do it 'enqueues to the digests queue' do
expect(described_class.new.queue_name).to eq('digests') expect(described_class.new.queue_name).to eq('digests')
end end
context 'when YearlyDigests::CalculateYear raises an error' do context 'when Users::Digests::CalculateYear raises an error' do
before do before do
allow_any_instance_of(YearlyDigests::CalculateYear).to receive(:call).and_raise(StandardError.new('Test error')) allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call).and_raise(StandardError.new('Test error'))
end end
it 'creates an error notification' do it 'creates an error notification' do
@ -38,7 +38,7 @@ RSpec.describe YearlyDigests::CalculatingJob, type: :job do
context 'when user does not exist' do context 'when user does not exist' do
before do before do
allow_any_instance_of(YearlyDigests::CalculateYear).to receive(:call).and_raise(ActiveRecord::RecordNotFound) allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call).and_raise(ActiveRecord::RecordNotFound)
end end
it 'does not raise error' do it 'does not raise error' do

View file

@ -2,17 +2,17 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigests::EmailSendingJob, type: :job do RSpec.describe Users::Digests::EmailSendingJob, type: :job do
describe '#perform' do describe '#perform' do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let(:year) { 2024 } let(:year) { 2024 }
let!(:digest) { create(:yearly_digest, user: user, year: year, period_type: :yearly) } let!(:digest) { create(:users_digest, user: user, year: year, period_type: :yearly) }
subject { described_class.perform_now(user.id, year) } subject { described_class.perform_now(user.id, year) }
before do before do
# Mock the mailer # Mock the mailer
allow(YearlyDigestsMailer).to receive_message_chain(:with, :year_end_digest, :deliver_later) allow(Users::DigestsMailer).to receive_message_chain(:with, :year_end_digest, :deliver_later)
end end
it 'enqueues to the mailers queue' do it 'enqueues to the mailers queue' do
@ -23,7 +23,7 @@ RSpec.describe YearlyDigests::EmailSendingJob, type: :job do
it 'sends the email' do it 'sends the email' do
subject subject
expect(YearlyDigestsMailer).to have_received(:with).with(user: user, digest: digest) expect(Users::DigestsMailer).to have_received(:with).with(user: user, digest: digest)
end end
it 'updates the sent_at timestamp' do it 'updates the sent_at timestamp' do
@ -39,7 +39,7 @@ RSpec.describe YearlyDigests::EmailSendingJob, type: :job do
it 'does not send the email' do it 'does not send the email' do
subject subject
expect(YearlyDigestsMailer).not_to have_received(:with) expect(Users::DigestsMailer).not_to have_received(:with)
end end
end end
@ -49,7 +49,7 @@ RSpec.describe YearlyDigests::EmailSendingJob, type: :job do
it 'does not send the email' do it 'does not send the email' do
subject subject
expect(YearlyDigestsMailer).not_to have_received(:with) expect(Users::DigestsMailer).not_to have_received(:with)
end end
end end
@ -59,7 +59,7 @@ RSpec.describe YearlyDigests::EmailSendingJob, type: :job do
it 'does not send the email again' do it 'does not send the email again' do
subject subject
expect(YearlyDigestsMailer).not_to have_received(:with) expect(Users::DigestsMailer).not_to have_received(:with)
end end
end end
@ -72,7 +72,7 @@ RSpec.describe YearlyDigests::EmailSendingJob, type: :job do
it 'reports the exception' do it 'reports the exception' do
expect(ExceptionReporter).to receive(:call).with( expect(ExceptionReporter).to receive(:call).with(
'YearlyDigests::EmailSendingJob', 'Users::Digests::EmailSendingJob',
anything anything
) )

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do RSpec.describe Users::Digests::YearEndSchedulingJob, type: :job do
describe '#perform' do describe '#perform' do
subject { described_class.perform_now } subject { described_class.perform_now }
@ -25,40 +25,40 @@ RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do
create(:stat, user: trial_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) create(:stat, user: inactive_user, year: previous_year, month: 1)
allow(YearlyDigests::CalculatingJob).to receive(:perform_later) allow(Users::Digests::CalculatingJob).to receive(:perform_later)
allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
end end
it 'schedules jobs for active users' do it 'schedules jobs for active users' do
subject subject
expect(YearlyDigests::CalculatingJob).to have_received(:perform_later) expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
.with(active_user.id, previous_year) .with(active_user.id, previous_year)
end end
it 'schedules jobs for trial users' do it 'schedules jobs for trial users' do
subject subject
expect(YearlyDigests::CalculatingJob).to have_received(:perform_later) expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
.with(trial_user.id, previous_year) .with(trial_user.id, previous_year)
end end
it 'does not schedule jobs for inactive users' do it 'does not schedule jobs for inactive users' do
subject subject
expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later) expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
.with(inactive_user.id, anything) .with(inactive_user.id, anything)
end end
it 'schedules email sending job with delay' do it 'schedules email sending job with delay' do
email_job_double = double(perform_later: nil) email_job_double = double(perform_later: nil)
allow(YearlyDigests::EmailSendingJob).to receive(:set) allow(Users::Digests::EmailSendingJob).to receive(:set)
.with(wait: 30.minutes) .with(wait: 30.minutes)
.and_return(email_job_double) .and_return(email_job_double)
subject subject
expect(YearlyDigests::EmailSendingJob).to have_received(:set) expect(Users::Digests::EmailSendingJob).to have_received(:set)
.with(wait: 30.minutes).at_least(:twice) .with(wait: 30.minutes).at_least(:twice)
end end
end end
@ -70,21 +70,21 @@ RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do
before do before do
create(:stat, user: user_with_stats, year: previous_year, month: 1) create(:stat, user: user_with_stats, year: previous_year, month: 1)
allow(YearlyDigests::CalculatingJob).to receive(:perform_later) allow(Users::Digests::CalculatingJob).to receive(:perform_later)
allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
end end
it 'does not schedule jobs for user without stats' do it 'does not schedule jobs for user without stats' do
subject subject
expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later) expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
.with(user_without_stats.id, anything) .with(user_without_stats.id, anything)
end end
it 'schedules jobs for user with stats' do it 'schedules jobs for user with stats' do
subject subject
expect(YearlyDigests::CalculatingJob).to have_received(:perform_later) expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
.with(user_with_stats.id, previous_year) .with(user_with_stats.id, previous_year)
end end
end end
@ -95,14 +95,14 @@ RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do
before do before do
create(:stat, user: user_current_year_only, year: Time.current.year, month: 1) create(:stat, user: user_current_year_only, year: Time.current.year, month: 1)
allow(YearlyDigests::CalculatingJob).to receive(:perform_later) allow(Users::Digests::CalculatingJob).to receive(:perform_later)
allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
end end
it 'does not schedule jobs for that user' do it 'does not schedule jobs for that user' do
subject subject
expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later) expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
.with(user_current_year_only.id, anything) .with(user_current_year_only.id, anything)
end end
end end

View 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

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigest, type: :model do RSpec.describe Users::Digest, type: :model do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:user) }
end end
@ -13,22 +13,22 @@ RSpec.describe YearlyDigest, type: :model do
describe 'uniqueness of year within scope' do describe 'uniqueness of year within scope' do
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:existing_digest) { create(:yearly_digest, user: user, year: 2024, period_type: :yearly) } 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 it 'does not allow duplicate yearly digest for same user and year' do
duplicate = build(:yearly_digest, user: user, year: 2024, period_type: :yearly) duplicate = build(:users_digest, user: user, year: 2024, period_type: :yearly)
expect(duplicate).not_to be_valid expect(duplicate).not_to be_valid
expect(duplicate.errors[:year]).to include('has already been taken') expect(duplicate.errors[:year]).to include('has already been taken')
end end
it 'allows same year for different period types' do it 'allows same year for different period types' do
monthly = build(:yearly_digest, user: user, year: 2024, period_type: :monthly) monthly = build(:users_digest, user: user, year: 2024, period_type: :monthly)
expect(monthly).to be_valid expect(monthly).to be_valid
end end
it 'allows same year for different users' do it 'allows same year for different users' do
other_user = create(:user) other_user = create(:user)
other_digest = build(:yearly_digest, user: other_user, year: 2024, period_type: :yearly) other_digest = build(:users_digest, user: other_user, year: 2024, period_type: :yearly)
expect(other_digest).to be_valid expect(other_digest).to be_valid
end end
end end
@ -41,14 +41,14 @@ RSpec.describe YearlyDigest, type: :model do
describe 'callbacks' do describe 'callbacks' do
describe 'before_create :generate_sharing_uuid' do describe 'before_create :generate_sharing_uuid' do
it 'generates a sharing_uuid if not present' do it 'generates a sharing_uuid if not present' do
digest = build(:yearly_digest, sharing_uuid: nil) digest = build(:users_digest, sharing_uuid: nil)
digest.save! digest.save!
expect(digest.sharing_uuid).to be_present expect(digest.sharing_uuid).to be_present
end end
it 'does not overwrite existing sharing_uuid' do it 'does not overwrite existing sharing_uuid' do
existing_uuid = SecureRandom.uuid existing_uuid = SecureRandom.uuid
digest = build(:yearly_digest, sharing_uuid: existing_uuid) digest = build(:users_digest, sharing_uuid: existing_uuid)
digest.save! digest.save!
expect(digest.sharing_uuid).to eq(existing_uuid) expect(digest.sharing_uuid).to eq(existing_uuid)
end end
@ -57,7 +57,7 @@ RSpec.describe YearlyDigest, type: :model do
describe 'helper methods' do describe 'helper methods' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:digest) { create(:yearly_digest, user: user) } let(:digest) { create(:users_digest, user: user) }
describe '#countries_count' do describe '#countries_count' do
it 'returns count of countries from toponyms' do it 'returns count of countries from toponyms' do
@ -133,7 +133,7 @@ RSpec.describe YearlyDigest, type: :model do
end end
context 'when no previous year data' do context 'when no previous year data' do
let(:digest) { create(:yearly_digest, :without_previous_year, user: user) } let(:digest) { create(:users_digest, :without_previous_year, user: user) }
it 'returns nil' do it 'returns nil' do
expect(digest.yoy_distance_change).to be_nil expect(digest.yoy_distance_change).to be_nil
@ -190,7 +190,7 @@ RSpec.describe YearlyDigest, type: :model do
describe 'sharing settings' do describe 'sharing settings' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:digest) { create(:yearly_digest, user: user) } let(:digest) { create(:users_digest, user: user) }
describe '#sharing_enabled?' do describe '#sharing_enabled?' do
context 'when sharing_settings is nil' do context 'when sharing_settings is nil' do
@ -408,7 +408,7 @@ RSpec.describe YearlyDigest, type: :model do
describe 'DistanceConvertible' do describe 'DistanceConvertible' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:digest) { create(:yearly_digest, user: user, distance: 10_000) } # 10 km let(:digest) { create(:users_digest, user: user, distance: 10_000) } # 10 km
describe '#distance_in_unit' do describe '#distance_in_unit' do
it 'converts distance to kilometers' do it 'converts distance to kilometers' do

View file

@ -2,15 +2,15 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Shared::YearlyDigests', type: :request do RSpec.describe 'Shared::Digests', type: :request do
context 'public sharing' do context 'public sharing' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:digest) { create(:yearly_digest, :with_sharing_enabled, user:, year: 2024) } let(:digest) { create(:users_digest, :with_sharing_enabled, user:, year: 2024) }
describe 'GET /shared/year/:uuid' do describe 'GET /shared/digest/:uuid' do
context 'with valid sharing UUID' do context 'with valid sharing UUID' do
it 'renders the public year view' do it 'renders the public year view' do
get shared_yearly_digest_url(digest.sharing_uuid) get shared_users_digest_url(digest.sharing_uuid)
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.body).to include('Year in Review') expect(response.body).to include('Year in Review')
@ -18,7 +18,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end end
it 'includes required content in response' do it 'includes required content in response' do
get shared_yearly_digest_url(digest.sharing_uuid) get shared_users_digest_url(digest.sharing_uuid)
expect(response.body).to include('2024') expect(response.body).to include('2024')
expect(response.body).to include('Distance traveled') expect(response.body).to include('Distance traveled')
@ -28,7 +28,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
context 'with invalid sharing UUID' do context 'with invalid sharing UUID' do
it 'redirects to root with alert' do it 'redirects to root with alert' do
get shared_yearly_digest_url('invalid-uuid') get shared_users_digest_url('invalid-uuid')
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq('Shared digest not found or no longer available') expect(flash[:alert]).to eq('Shared digest not found or no longer available')
@ -36,10 +36,10 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end end
context 'with expired sharing' do context 'with expired sharing' do
let(:digest) { create(:yearly_digest, :with_sharing_expired, user:, year: 2024) } let(:digest) { create(:users_digest, :with_sharing_expired, user:, year: 2024) }
it 'redirects to root with alert' do it 'redirects to root with alert' do
get shared_yearly_digest_url(digest.sharing_uuid) get shared_users_digest_url(digest.sharing_uuid)
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq('Shared digest not found or no longer available') expect(flash[:alert]).to eq('Shared digest not found or no longer available')
@ -47,10 +47,10 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end end
context 'with disabled sharing' do context 'with disabled sharing' do
let(:digest) { create(:yearly_digest, :with_sharing_disabled, user:, year: 2024) } let(:digest) { create(:users_digest, :with_sharing_disabled, user:, year: 2024) }
it 'redirects to root with alert' do it 'redirects to root with alert' do
get shared_yearly_digest_url(digest.sharing_uuid) get shared_users_digest_url(digest.sharing_uuid)
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq('Shared digest not found or no longer available') expect(flash[:alert]).to eq('Shared digest not found or no longer available')
@ -58,15 +58,15 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end end
end end
describe 'PATCH /yearly_digests/:year/sharing' do describe 'PATCH /digests/:year/sharing' do
context 'when user is signed in' do context 'when user is signed in' do
let!(:digest_to_share) { create(:yearly_digest, user:, year: 2024) } let!(:digest_to_share) { create(:users_digest, user:, year: 2024) }
before { sign_in user } before { sign_in user }
context 'enabling sharing' do context 'enabling sharing' do
it 'enables sharing and returns success' do it 'enables sharing and returns success' do
patch sharing_yearly_digest_path(year: 2024), patch sharing_users_digest_path(year: 2024),
params: { enabled: '1' }, params: { enabled: '1' },
as: :json as: :json
@ -83,7 +83,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end end
it 'sets custom expiration when provided' do it 'sets custom expiration when provided' do
patch sharing_yearly_digest_path(year: 2024), patch sharing_users_digest_path(year: 2024),
params: { enabled: '1', expiration: '12h' }, params: { enabled: '1', expiration: '12h' },
as: :json as: :json
@ -94,10 +94,10 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end end
context 'disabling sharing' do context 'disabling sharing' do
let!(:enabled_digest) { create(:yearly_digest, :with_sharing_enabled, user:, year: 2023) } let!(:enabled_digest) { create(:users_digest, :with_sharing_enabled, user:, year: 2023) }
it 'disables sharing and returns success' do it 'disables sharing and returns success' do
patch sharing_yearly_digest_path(year: 2023), patch sharing_users_digest_path(year: 2023),
params: { enabled: '0' }, params: { enabled: '0' },
as: :json as: :json
@ -114,7 +114,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
context 'when digest does not exist' do context 'when digest does not exist' do
it 'returns not found' do it 'returns not found' do
patch sharing_yearly_digest_path(year: 2020), patch sharing_users_digest_path(year: 2020),
params: { enabled: '1' }, params: { enabled: '1' },
as: :json as: :json
@ -125,7 +125,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
context 'when user is not signed in' do context 'when user is not signed in' do
it 'returns unauthorized' do it 'returns unauthorized' do
patch sharing_yearly_digest_path(year: 2024), patch sharing_users_digest_path(year: 2024),
params: { enabled: '1' }, params: { enabled: '1' },
as: :json as: :json

View file

@ -2,11 +2,11 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe '/yearly_digests', type: :request do RSpec.describe '/digests', type: :request do
context 'when user is not signed in' do context 'when user is not signed in' do
describe 'GET /index' do describe 'GET /index' do
it 'redirects to the sign in page' do it 'redirects to the sign in page' do
get yearly_digests_url get users_digests_url
expect(response.status).to eq(302) expect(response.status).to eq(302)
end end
@ -14,7 +14,7 @@ RSpec.describe '/yearly_digests', type: :request do
describe 'GET /show' do describe 'GET /show' do
it 'redirects to the sign in page' do it 'redirects to the sign in page' do
get yearly_digest_url(year: 2024) get users_digest_url(year: 2024)
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
@ -22,7 +22,7 @@ RSpec.describe '/yearly_digests', type: :request do
describe 'POST /create' do describe 'POST /create' do
it 'redirects to the sign in page' do it 'redirects to the sign in page' do
post yearly_digests_url, params: { year: 2024 } post users_digests_url, params: { year: 2024 }
expect(response.status).to eq(302) expect(response.status).to eq(302)
end end
@ -36,46 +36,46 @@ RSpec.describe '/yearly_digests', type: :request do
describe 'GET /index' do describe 'GET /index' do
it 'renders a successful response' do it 'renders a successful response' do
get yearly_digests_url get users_digests_url
expect(response.status).to eq(200) expect(response.status).to eq(200)
end end
it 'displays existing digests' do it 'displays existing digests' do
digest = create(:yearly_digest, user:, year: 2024) digest = create(:users_digest, user:, year: 2024)
get yearly_digests_url get users_digests_url
expect(response.body).to include('2024') expect(response.body).to include('2024')
end end
it 'shows empty state when no digests exist' do it 'shows empty state when no digests exist' do
get yearly_digests_url get users_digests_url
expect(response.body).to include('No Year-End Digests Yet') expect(response.body).to include('No Year-End Digests Yet')
end end
end end
describe 'GET /show' do describe 'GET /show' do
let!(:digest) { create(:yearly_digest, user:, year: 2024) } let!(:digest) { create(:users_digest, user:, year: 2024) }
it 'renders a successful response' do it 'renders a successful response' do
get yearly_digest_url(year: 2024) get users_digest_url(year: 2024)
expect(response.status).to eq(200) expect(response.status).to eq(200)
end end
it 'includes digest content' do it 'includes digest content' do
get yearly_digest_url(year: 2024) get users_digest_url(year: 2024)
expect(response.body).to include('2024 Year in Review') expect(response.body).to include('2024 Year in Review')
expect(response.body).to include('Distance Traveled') expect(response.body).to include('Distance Traveled')
end end
it 'redirects when digest not found' do it 'redirects when digest not found' do
get yearly_digest_url(year: 2020) get users_digest_url(year: 2020)
expect(response).to redirect_to(yearly_digests_path) expect(response).to redirect_to(users_digests_path)
expect(flash[:alert]).to eq('Digest not found') expect(flash[:alert]).to eq('Digest not found')
end end
end end
@ -86,39 +86,39 @@ RSpec.describe '/yearly_digests', type: :request do
create(:stat, user:, year: 2024, month: 1) create(:stat, user:, year: 2024, month: 1)
end end
it 'enqueues YearlyDigests::CalculatingJob' do it 'enqueues Users::Digests::CalculatingJob' do
post yearly_digests_url, params: { year: 2024 } post users_digests_url, params: { year: 2024 }
expect(YearlyDigests::CalculatingJob).to have_been_enqueued.with(user.id, 2024) expect(Users::Digests::CalculatingJob).to have_been_enqueued.with(user.id, 2024)
end end
it 'redirects with success notice' do it 'redirects with success notice' do
post yearly_digests_url, params: { year: 2024 } post users_digests_url, params: { year: 2024 }
expect(response).to redirect_to(yearly_digests_path) expect(response).to redirect_to(users_digests_path)
expect(flash[:notice]).to include('is being generated') expect(flash[:notice]).to include('is being generated')
end end
end end
context 'with invalid year' do context 'with invalid year' do
it 'redirects with alert for year with no stats' do it 'redirects with alert for year with no stats' do
post yearly_digests_url, params: { year: 2024 } post users_digests_url, params: { year: 2024 }
expect(response).to redirect_to(yearly_digests_path) expect(response).to redirect_to(users_digests_path)
expect(flash[:alert]).to eq('Invalid year selected') expect(flash[:alert]).to eq('Invalid year selected')
end end
it 'redirects with alert for year before 2000' do it 'redirects with alert for year before 2000' do
post yearly_digests_url, params: { year: 1999 } post users_digests_url, params: { year: 1999 }
expect(response).to redirect_to(yearly_digests_path) expect(response).to redirect_to(users_digests_path)
expect(flash[:alert]).to eq('Invalid year selected') expect(flash[:alert]).to eq('Invalid year selected')
end end
it 'redirects with alert for future year' do it 'redirects with alert for future year' do
post yearly_digests_url, params: { year: Time.current.year + 1 } post users_digests_url, params: { year: Time.current.year + 1 }
expect(response).to redirect_to(yearly_digests_path) expect(response).to redirect_to(users_digests_path)
expect(flash[:alert]).to eq('Invalid year selected') expect(flash[:alert]).to eq('Invalid year selected')
end end
end end
@ -130,7 +130,7 @@ RSpec.describe '/yearly_digests', type: :request do
end end
it 'returns an unauthorized response' do it 'returns an unauthorized response' do
post yearly_digests_url, params: { year: 2024 } post users_digests_url, params: { year: 2024 }
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:notice]).to eq('Your account is not active.') expect(flash[:notice]).to eq('Your account is not active.')

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigests::CalculateYear do RSpec.describe Users::Digests::CalculateYear do
describe '#call' do describe '#call' do
subject(:calculate_digest) { described_class.new(user.id, year).call } subject(:calculate_digest) { described_class.new(user.id, year).call }
@ -15,7 +15,7 @@ RSpec.describe YearlyDigests::CalculateYear do
end end
it 'does not create a digest' do it 'does not create a digest' do
expect { calculate_digest }.not_to(change { YearlyDigest.count }) expect { calculate_digest }.not_to(change { Users::Digest.count })
end end
end end
@ -38,11 +38,11 @@ RSpec.describe YearlyDigests::CalculateYear do
end end
it 'creates a yearly digest' do it 'creates a yearly digest' do
expect { calculate_digest }.to change { YearlyDigest.count }.by(1) expect { calculate_digest }.to change { Users::Digest.count }.by(1)
end end
it 'returns the created digest' do it 'returns the created digest' do
expect(calculate_digest).to be_a(YearlyDigest) expect(calculate_digest).to be_a(Users::Digest)
end end
it 'sets the correct year' do it 'sets the correct year' do
@ -57,18 +57,23 @@ RSpec.describe YearlyDigests::CalculateYear do
expect(calculate_digest.distance).to eq(125_000) expect(calculate_digest.distance).to eq(125_000)
end end
it 'aggregates countries' do it 'aggregates countries with their cities' do
expect(calculate_digest.toponyms['countries']).to contain_exactly('France', 'Germany') toponyms = calculate_digest.toponyms
end
it 'aggregates cities' do countries = toponyms.map { |t| t['country'] }
expect(calculate_digest.toponyms['cities']).to contain_exactly('Berlin', 'Munich', 'Paris') 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 end
it 'builds monthly distances' do it 'builds monthly distances' do
expect(calculate_digest.monthly_distances['1']).to eq(50_000) expect(calculate_digest.monthly_distances['1']).to eq('50000')
expect(calculate_digest.monthly_distances['2']).to eq(75_000) expect(calculate_digest.monthly_distances['2']).to eq('75000')
expect(calculate_digest.monthly_distances['3']).to eq(0) # Missing month expect(calculate_digest.monthly_distances['3']).to eq('0') # Missing month
end end
it 'calculates time spent by location' do it 'calculates time spent by location' do
@ -81,16 +86,16 @@ RSpec.describe YearlyDigests::CalculateYear do
end end
it 'calculates all time stats' do it 'calculates all time stats' do
expect(calculate_digest.all_time_stats['total_distance']).to eq(125_000) expect(calculate_digest.all_time_stats['total_distance']).to eq('125000')
end end
context 'when digest already exists' do context 'when digest already exists' do
let!(:existing_digest) do let!(:existing_digest) do
create(:yearly_digest, user: user, year: 2024, period_type: :yearly, distance: 10_000) create(:users_digest, user: user, year: 2024, period_type: :yearly, distance: 10_000)
end end
it 'updates the existing digest' do it 'updates the existing digest' do
expect { calculate_digest }.not_to(change { YearlyDigest.count }) expect { calculate_digest }.not_to(change { Users::Digest.count })
end end
it 'updates the distance' do it 'updates the distance' do

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigests::FirstTimeVisitsCalculator do RSpec.describe Users::Digests::FirstTimeVisitsCalculator do
describe '#call' do describe '#call' do
subject(:calculator) { described_class.new(user, year).call } subject(:calculator) { described_class.new(user, year).call }

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe YearlyDigests::YearOverYearCalculator do RSpec.describe Users::Digests::YearOverYearCalculator do
describe '#call' do describe '#call' do
subject(:calculator) { described_class.new(user, year).call } subject(:calculator) { described_class.new(user, year).call }

View file

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