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
## 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
- 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 'gpx'
gem 'groupdate'
gem 'grover'
gem 'h3', '~> 3.7'
gem 'httparty'
gem 'importmap-rails'

View file

@ -204,8 +204,6 @@ GEM
rake
groupdate (6.7.0)
activesupport (>= 7.1)
grover (1.2.4)
nokogiri (~> 1)
h3 (3.7.4)
ffi (~> 1.9)
rgeo-geojson (~> 2.1)
@ -659,7 +657,6 @@ DEPENDENCIES
geocoder!
gpx
groupdate
grover
h3 (~> 3.7)
httparty
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
class Shared::YearlyDigestsController < ApplicationController
helper YearlyDigestsHelper
class Shared::DigestsController < ApplicationController
helper Users::DigestsHelper
helper CountryFlagHelper
before_action :authenticate_user!, except: [:show]
before_action :authenticate_active_user!, only: [:update]
def show
@digest = YearlyDigest.find_by(sharing_uuid: params[:uuid])
@digest = Users::Digest.find_by(sharing_uuid: params[:uuid])
unless @digest&.public_accessible?
return redirect_to root_path,
@ -19,18 +20,18 @@ class Shared::YearlyDigestsController < ApplicationController
@distance_unit = @user.safe_settings.distance_unit || 'km'
@is_public_view = true
render 'yearly_digests/public_year'
render 'users/digests/public_year'
end
def update
@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
if params[:enabled] == '1'
@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: {
success: true,

View file

@ -1,14 +1,15 @@
# frozen_string_literal: true
class YearlyDigestsController < ApplicationController
helper YearlyDigestsHelper
class Users::DigestsController < ApplicationController
helper Users::DigestsHelper
helper CountryFlagHelper
before_action :authenticate_user!
before_action :authenticate_active_user!, only: [:create]
before_action :set_digest, only: [:show]
def index
@digests = current_user.yearly_digests.yearly.order(year: :desc)
@digests = current_user.digests.yearly.order(year: :desc)
@available_years = available_years_for_generation
end
@ -20,26 +21,26 @@ class YearlyDigestsController < ApplicationController
year = params[:year].to_i
if valid_year?(year)
YearlyDigests::CalculatingJob.perform_later(current_user.id, year)
redirect_to yearly_digests_path,
Users::Digests::CalculatingJob.perform_later(current_user.id, year)
redirect_to users_digests_path,
notice: "Year-end digest for #{year} is being generated. Check back soon!",
status: :see_other
else
redirect_to yearly_digests_path, alert: 'Invalid year selected', status: :see_other
redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
end
end
private
def set_digest
@digest = current_user.yearly_digests.yearly.find_by!(year: params[:year])
@digest = current_user.digests.yearly.find_by!(year: params[:year])
rescue ActiveRecord::RecordNotFound
redirect_to yearly_digests_path, alert: 'Digest not found'
redirect_to users_digests_path, alert: 'Digest not found'
end
def available_years_for_generation
tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
existing_digests = current_user.yearly_digests.yearly.pluck(:year)
existing_digests = current_user.digests.yearly.pluck(:year)
(tracked_years - existing_digests).sort.reverse
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
class YearlyDigests::CalculatingJob < ApplicationJob
class Users::Digests::CalculatingJob < ApplicationJob
queue_as :digests
def perform(user_id, year)
YearlyDigests::CalculateYear.new(user_id, year).call
Users::Digests::CalculateYear.new(user_id, year).call
rescue StandardError => e
create_digest_failed_notification(user_id, e)
end
@ -21,6 +21,6 @@ class YearlyDigests::CalculatingJob < ApplicationJob
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call
rescue ActiveRecord::RecordNotFound
# User was deleted, nothing to notify
nil
end
end

View file

@ -1,20 +1,20 @@
# frozen_string_literal: true
class YearlyDigests::EmailSendingJob < ApplicationJob
class Users::Digests::EmailSendingJob < ApplicationJob
queue_as :mailers
def perform(user_id, year)
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)
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)
rescue ActiveRecord::RecordNotFound
ExceptionReporter.call(
'YearlyDigests::EmailSendingJob',
'Users::Digests::EmailSendingJob',
"User with ID #{user_id} not found. Skipping year-end digest email."
)
end
@ -23,8 +23,8 @@ class YearlyDigests::EmailSendingJob < ApplicationJob
def should_send_email?(user, digest)
return false unless user.safe_settings.digest_emails_enabled?
return false unless digest.present?
return false if digest.sent_at.present? # Already sent
return false if digest.blank?
return false if digest.sent_at.present?
true
end

View file

@ -1,20 +1,20 @@
# frozen_string_literal: true
class YearlyDigests::YearEndSchedulingJob < ApplicationJob
class Users::Digests::YearEndSchedulingJob < ApplicationJob
queue_as :digests
def perform
year = Time.current.year - 1 # Previous year's digest
User.active_or_trial.find_each do |user|
::User.active_or_trial.find_each do |user|
# Skip if user has no data for the year
next unless user.stats.where(year: year).exists?
# Schedule calculation first
YearlyDigests::CalculatingJob.perform_later(user.id, year)
Users::Digests::CalculatingJob.perform_later(user.id, year)
# Schedule email with delay to allow calculation to complete
YearlyDigests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year)
Users::Digests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year)
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')
# 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
when '1h' then 1.hour.from_now
when '12h' then 12.hours.from_now
when '24h' then 24.hours.from_now
when '1w' then 1.week.from_now
when '1m' then 1.month.from_now
end
update!(

View file

@ -21,7 +21,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_many :trips, dependent: :destroy
has_many :tracks, dependent: :destroy
has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy
has_many :yearly_digests, dependent: :destroy
has_many :digests, class_name: 'Users::Digest', dependent: :destroy
after_create :create_api_key
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class YearlyDigest < ApplicationRecord
class Users::Digest < ApplicationRecord
self.table_name = 'digests'
include DistanceConvertible
@ -10,15 +10,13 @@ class YearlyDigest < ApplicationRecord
belongs_to :user
validates :year, presence: true
validates :period_type, presence: true
validates :year, :period_type, presence: true
validates :year, uniqueness: { scope: %i[user_id period_type] }
before_create :generate_sharing_uuid
enum :period_type, { monthly: 0, yearly: 1 }
# Sharing methods (following Stat model pattern)
def sharing_enabled?
sharing_settings.try(:[], 'enabled') == true
end
@ -48,12 +46,14 @@ class YearlyDigest < ApplicationRecord
end
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
when '1h' then 1.hour.from_now
when '12h' then 12.hours.from_now
when '24h' then 24.hours.from_now
when '1w' then 1.week.from_now
when '1m' then 1.month.from_now
end
update!(
@ -76,8 +76,6 @@ class YearlyDigest < ApplicationRecord
)
end
# Helper methods for accessing digest data
# toponyms is an array like: [{'country' => 'Germany', 'cities' => [{'city' => 'Berlin'}]}]
def countries_count
return 0 unless toponyms.is_a?(Array)
@ -131,7 +129,7 @@ class YearlyDigest < ApplicationRecord
end
def total_distance_all_time
all_time_stats['total_distance'] || 0
(all_time_stats['total_distance'] || 0).to_i
end
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,
'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true',
'enabled_map_layers' => ['Routes', 'Heatmap'],
'enabled_map_layers' => %w[Routes Heatmap],
'maps_maplibre_style' => 'light',
'digest_emails_enabled' => true
}.freeze
@ -142,6 +142,9 @@ class Users::SafeSettings
end
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

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([
['1 hour', '1h'],
['12 hours', '12h'],
['24 hours', '24h']
['24 hours', '24h'],
['1 week', '1w'],
['1 month', '1m']
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
</select>
</div>

View file

@ -3,7 +3,7 @@
<div class="w-full my-5">
<div class="flex justify-between items-center mb-6">
<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
<% end %>
</div>

View file

@ -1,7 +1,7 @@
<% content_for :title, 'Year-End Digests' %>
<div class="w-full my-5">
<div class="flex justify-between items-center mb-6">
<div class="max-w-screen-2xl mx-auto my-5 px-4">
<div class="flex justify-between items-center mb-6 gap-8">
<h1 class="text-3xl font-bold flex items-center gap-2">
<%= icon 'earth' %> Year-End Digests
</h1>
@ -9,12 +9,12 @@
<% if @available_years.any? && current_user.active? %>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-primary">
<%= icon 'plus' %> Generate Digest
<%= icon 'calendar-plus-2' %> Generate Digest
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<% @available_years.each do |year| %>
<li>
<%= link_to year, yearly_digests_path(year: year),
<%= link_to year, users_digests_path(year: year),
data: { turbo_method: :post },
class: 'text-base' %>
</li>
@ -27,8 +27,9 @@
<% if @digests.empty? %>
<div class="card bg-base-200 shadow-xl">
<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">No Year-End Digests Yet</h2>
<h2 class="text-xl font-semibold mb-2 flex items-center justify-center gap-2">
<%= icon 'earth' %>No Year-End Digests Yet
</h2>
<p class="text-gray-500 mb-4">
Year-end digests are automatically generated on January 1st each year.
<% if @available_years.any? && current_user.active? %>
@ -38,18 +39,18 @@
</div>
</div>
<% 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| %>
<div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title text-2xl justify-between">
<%= link_to digest.year, 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? %>
<span class="badge badge-success badge-sm">Shared</span>
<% end %>
</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-title">Distance</div>
<div class="stat-value text-primary text-lg">
@ -58,20 +59,20 @@
</div>
<div class="stat">
<div class="stat-title">Countries</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? %>
<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
</div>
<% end %>
</div>
<div class="stat">
<div class="stat-title">Cities</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? %>
<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
</div>
<% end %>
@ -79,7 +80,7 @@
</div>
<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
<% end %>
</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 -->
<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="max-w-lg">
<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-title">Countries visited</div>
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
<% if @digest.first_time_countries.any? %>
<div class="stat-desc text-success"><%= @digest.first_time_countries.count %> first time</div>
<% end %>
<div class="stat-desc <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
<%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
</div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title">Cities explored</div>
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
<% if @digest.first_time_cities.any? %>
<div class="stat-desc text-success"><%= @digest.first_time_cities.count %> first time</div>
<% end %>
<div class="stat-desc <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
<%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
</div>
</div>
</div>
<!-- First Time Visits -->
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'star' %> First Time Visits
</h2>
@ -45,7 +45,7 @@
<% if @digest.first_time_countries.any? %>
<div class="mb-4">
<h3 class="font-semibold mb-2">New Countries</h3>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_countries.each do |country| %>
<span class="badge badge-success badge-lg"><%= country %></span>
<% end %>
@ -56,7 +56,7 @@
<% if @digest.first_time_cities.any? %>
<div>
<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| %>
<span class="badge badge-outline"><%= city %></span>
<% end %>
@ -73,14 +73,14 @@
<!-- Monthly Distance Chart -->
<% if @digest.monthly_distances.present? %>
<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">
<%= icon 'activity' %> Year by Month
</h2>
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
<%= column_chart(
@digest.monthly_distances.sort.map { |month, distance_meters|
[Date::ABBR_MONTHNAMES[month.to_i], 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',
suffix: " #{@distance_unit}",
@ -101,14 +101,17 @@
<!-- Top Countries by Time Spent -->
<% if @digest.top_countries_by_time.any? %>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'map-pin' %> Where They Spent the Most Time
</h2>
<ul class="space-y-2">
<ul class="space-y-2 w-full">
<% @digest.top_countries_by_time.take(3).each do |country| %>
<li class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
<span class="font-semibold"><%= 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>
</li>
<% end %>
@ -119,15 +122,18 @@
<!-- Countries & Cities -->
<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">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<div class="space-y-4 w-full">
<% @digest.toponyms&.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold"><%= 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>
</div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress>
@ -137,7 +143,7 @@
<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>
<% @digest.toponyms&.each do |country| %>
<% country['cities']&.take(5)&.each do |city| %>
@ -153,23 +159,23 @@
<!-- All-Time Stats -->
<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">
<%= icon 'trophy' %> All-Time Stats
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="stat">
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Countries visited</div>
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
</div>
<div class="stat">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Cities explored</div>
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
</div>
<div class="stat">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
<div class="stat place-items-center mt-2">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
</div>

View file

@ -1,8 +1,8 @@
<% content_for :title, "#{@digest.year} Year in Review" %>
<div class="w-full my-5">
<div class="max-w-xl mx-auto my-5">
<!-- 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="max-w-lg">
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
@ -17,7 +17,7 @@
<!-- Distance Card -->
<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">
<%= icon 'map' %> Distance Traveled
</div>
@ -40,11 +40,9 @@
<%= icon 'globe' %> Countries
</div>
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
<% if @digest.first_time_countries.any? %>
<div class="stat-desc text-success font-medium">
<%= icon 'star' %> <%= @digest.first_time_countries.count %> first time
</div>
<% end %>
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
<%= icon 'star' %> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
</div>
</div>
<div class="stat place-items-center">
@ -52,18 +50,16 @@
<%= icon 'building' %> Cities
</div>
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
<% if @digest.first_time_cities.any? %>
<div class="stat-desc text-success font-medium">
<%= icon 'star' %> <%= @digest.first_time_cities.count %> first time
</div>
<% end %>
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
<%= icon 'star' %> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
</div>
</div>
</div>
<!-- First Time Visits -->
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'star' %> First Time Visits
</h2>
@ -71,7 +67,7 @@
<% if @digest.first_time_countries.any? %>
<div class="mb-4">
<h3 class="font-semibold mb-2">New Countries</h3>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_countries.each do |country| %>
<span class="badge badge-success badge-lg"><%= country %></span>
<% end %>
@ -82,7 +78,7 @@
<% if @digest.first_time_cities.any? %>
<div>
<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| %>
<span class="badge badge-outline"><%= city %></span>
<% end %>
@ -99,14 +95,14 @@
<!-- Monthly Distance Chart -->
<% if @digest.monthly_distances.present? %>
<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">
<%= icon 'activity' %> Your Year, Month by Month
</h2>
<div class="w-full h-64 bg-base-100 rounded-lg p-4">
<%= column_chart(
@digest.monthly_distances.sort.map { |month, distance_meters|
[Date::ABBR_MONTHNAMES[month.to_i], 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',
suffix: " #{@distance_unit}",
@ -127,18 +123,21 @@
<!-- Top Countries by Time Spent -->
<% if @digest.top_countries_by_time.any? %>
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'map-pin' %> Where You Spent the Most Time
</h2>
<div class="space-y-4">
<div class="space-y-4 w-full">
<% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %>
<div class="flex justify-between items-center p-3 bg-base-100 rounded-lg">
<div class="flex items-center gap-3">
<span class="badge badge-lg <%= ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success'][index] %>">
<%= index + 1 %>
</span>
<span class="font-semibold"><%= country['name'] %></span>
<span class="font-semibold">
<span class="mr-1"><%= country_flag(country['name']) %></span>
<%= country['name'] %>
</span>
</div>
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
</div>
@ -150,11 +149,11 @@
<!-- All Countries & Cities -->
<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">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<div class="space-y-4 w-full">
<% if @digest.toponyms.present? %>
<% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %>
<% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
@ -166,7 +165,10 @@
<div class="space-y-2">
<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">
<%= pluralize(cities_count, 'city') %>
</span>
@ -183,30 +185,30 @@
<!-- All-Time Stats Footer -->
<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">
<%= icon 'trophy' %> All-Time Stats
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="stat">
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Countries visited</div>
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
</div>
<div class="stat">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Cities explored</div>
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
</div>
<div class="stat">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
<div class="stat place-items-center mt-2">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-4 justify-center">
<%= link_to yearly_digests_path, class: 'btn btn-outline' do %>
<%= link_to users_digests_path, class: 'btn btn-outline' do %>
Back to All Digests
<% end %>
<button class="btn btn-outline" onclick="sharing_modal.showModal()">
@ -227,7 +229,7 @@
</h3>
<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 -->
<div class="form-control mb-4">
@ -260,8 +262,10 @@
<%= options_for_select([
['1 hour', '1h'],
['12 hours', '12h'],
['24 hours', '24h']
], @digest&.sharing_settings&.dig('expiration') || '1h') %>
['24 hours', '24h'],
['1 week', '1w'],
['1 month', '1m']
], @digest&.sharing_settings&.dig('expiration') || '24h') %>
</select>
</div>
@ -275,7 +279,7 @@
readonly
class="input input-bordered join-item flex-1"
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"
class="btn btn-outline join-item"
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;
line-height: 1.6;
color: #333;
max-width: 600px;
max-width: 480px;
margin: 0 auto;
padding: 0;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #2563eb, #7c3aed);
background: linear-gradient(135deg, #0f766e, #0284c7);
color: white;
padding: 40px 30px;
text-align: center;
@ -40,6 +40,7 @@
padding: 20px;
margin: 16px 0;
border: 1px solid #e2e8f0;
text-align: center;
}
.stat-value {
font-size: 36px;
@ -113,6 +114,7 @@
border-radius: 12px;
padding: 24px;
margin: 20px 0;
text-align: center;
}
.all-time-footer h3 {
color: white;
@ -120,8 +122,6 @@
font-size: 18px;
}
.all-time-stat {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
@ -130,9 +130,13 @@
}
.all-time-stat .label {
opacity: 0.8;
display: block;
font-size: 12px;
margin-bottom: 4px;
}
.all-time-stat .value {
font-weight: 600;
font-size: 24px;
}
.footer {
text-align: center;
@ -162,6 +166,12 @@
<p>Your journey, by the numbers</p>
</div>
<div class="content">
<p>
Hi, this is Evgenii from Dawarich! Pretty wild journey last yeah, huh? Let's take a look back at all the places you explored in <strong><%= @digest.year %></strong>.
</p>
</div>
<div class="content">
<!-- Distance Traveled -->
<div class="stat-card">
@ -204,10 +214,34 @@
</div>
<!-- Monthly Distance Chart -->
<% if @chart_image_name %>
<% if @digest.monthly_distances.present? %>
<div class="chart-container">
<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>
<% end %>
@ -218,7 +252,7 @@
<ul class="location-list">
<% @digest.top_countries_by_time.take(3).each do |country| %>
<li>
<span><%= country['name'] %></span>
<span><%= country_flag(country['name']) %> <%= country['name'] %></span>
<span><%= format_time_spent(country['minutes']) %></span>
</li>
<% end %>
@ -229,21 +263,31 @@
<!-- All-Time Stats Footer -->
<div class="all-time-footer">
<h3>All-Time Stats</h3>
<div class="all-time-stat">
<span class="label">Countries visited</span>
<span class="value"><%= @digest.total_countries_all_time %></span>
</div>
<div class="all-time-stat">
<span class="label">Cities explored</span>
<span class="value"><%= @digest.total_cities_all_time %></span>
</div>
<div class="all-time-stat">
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 16px;">
<tr>
<td width="50%" style="text-align: center; padding: 8px;">
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Countries visited</div>
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_countries_all_time %></div>
</td>
<td width="50%" style="text-align: center; padding: 8px;">
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Cities explored</div>
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_cities_all_time %></div>
</td>
</tr>
</table>
<div class="all-time-stat" style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;">
<span class="label">Total distance</span>
<span class="value"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></span>
</div>
</div>
</div>
<div class="content">
<p>
You can open your digest for sharing on its page on Dawarich: <a href="<%= users_digest_url(year: @digest.year) %>"><%= users_digest_url(year: @digest.year) %></a>
</p>
</div>
<div class="footer">
<p>Powered by <a href="https://dawarich.app">Dawarich</a>, your personal location history.</p>
<p class="unsubscribe">

View file

@ -1,9 +1,7 @@
<%= @digest.year %> Year in Review
====================================
Hi <%= @user.email %>,
Here's your year in review!
Hi, this is Evgenii from Dawarich! Pretty wild journey last year, huh? Let's take a look back at all the places you explored in <%= @digest.year %>.
DISTANCE TRAVELED
<%= distance_with_unit(@digest.distance, @distance_unit) %>
@ -34,6 +32,8 @@ ALL-TIME STATS
- <%= @digest.total_cities_all_time %> cities explored
- <%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %> traveled
Keep exploring, keep discovering. Here's to even more adventures in <%= @digest.year + 1 %>!
--
Powered by Dawarich
https://dawarich.app

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

View file

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

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
# 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 :monthly_distances, default: {} # {1: meters, 2: meters, ...}
t.jsonb :time_spent_by_location, default: {} # Top locations by time

View file

@ -3,7 +3,7 @@
namespace :points do
namespace :raw_data do
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)
user_id = args[:user_id].to_i
@ -27,7 +27,7 @@ namespace :points do
end
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)
user_id = args[:user_id].to_i
@ -69,9 +69,9 @@ namespace :points do
puts ''
archives = Points::RawDataArchive.where(user_id: user_id)
.select(:year, :month)
.distinct
.order(:year, :month)
.select(:year, :month)
.distinct
.order(:year, :month)
puts "Found #{archives.count} months to restore"
puts ''
@ -113,9 +113,9 @@ namespace :points do
# Storage size via ActiveStorage
total_blob_size = ActiveStorage::Blob
.joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id')
.where("active_storage_attachments.record_type = 'Points::RawDataArchive'")
.sum(:byte_size)
.joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id')
.where("active_storage_attachments.record_type = 'Points::RawDataArchive'")
.sum(:byte_size)
puts "Storage used: #{ActiveSupport::NumberHelper.number_to_human_size(total_blob_size)}"
puts ''
@ -130,10 +130,10 @@ namespace :points do
puts '─────────────────────────────────────────────────'
Points::RawDataArchive.group(:user_id)
.select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points')
.order('archive_count DESC')
.limit(10)
.each_with_index do |stat, idx|
.select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points')
.order('archive_count DESC')
.limit(10)
.each_with_index do |stat, idx|
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"
end
@ -142,7 +142,7 @@ namespace :points do
end
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
if args[:user_id] && args[:year] && args[:month]
@ -177,7 +177,7 @@ namespace :points do
end
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
if args[:user_id] && args[:year] && args[:month]

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
namespace :webmanifest do
desc "Generate site.webmanifest in public directory with correct asset paths"
task :generate => :environment do
desc 'Generate site.webmanifest in public directory with correct asset paths'
task generate: :environment do
require 'erb'
# Make sure assets are compiled first by loading the manifest
@ -12,28 +14,28 @@ namespace :webmanifest do
# Generate the manifest content
manifest_content = {
"name": "Dawarich",
"short_name": "Dawarich",
"name": 'Dawarich',
"short_name": 'Dawarich',
"icons": [
{
"src": icon_192_path,
"sizes": "192x192",
"type": "image/png"
"sizes": '192x192',
"type": 'image/png'
},
{
"src": icon_512_path,
"sizes": "512x512",
"type": "image/png"
"sizes": '512x512',
"type": 'image/png'
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
"theme_color": '#ffffff',
"background_color": '#ffffff',
"display": 'standalone'
}.to_json
# Write to public/site.webmanifest
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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do
RSpec.describe Users::Digests::YearEndSchedulingJob, type: :job do
describe '#perform' do
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: inactive_user, year: previous_year, month: 1)
allow(YearlyDigests::CalculatingJob).to receive(:perform_later)
allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
allow(Users::Digests::CalculatingJob).to receive(:perform_later)
allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
end
it 'schedules jobs for active users' do
subject
expect(YearlyDigests::CalculatingJob).to have_received(:perform_later)
expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
.with(active_user.id, previous_year)
end
it 'schedules jobs for trial users' do
subject
expect(YearlyDigests::CalculatingJob).to have_received(:perform_later)
expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
.with(trial_user.id, previous_year)
end
it 'does not schedule jobs for inactive users' do
subject
expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later)
expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
.with(inactive_user.id, anything)
end
it 'schedules email sending job with delay' do
email_job_double = double(perform_later: nil)
allow(YearlyDigests::EmailSendingJob).to receive(:set)
allow(Users::Digests::EmailSendingJob).to receive(:set)
.with(wait: 30.minutes)
.and_return(email_job_double)
subject
expect(YearlyDigests::EmailSendingJob).to have_received(:set)
expect(Users::Digests::EmailSendingJob).to have_received(:set)
.with(wait: 30.minutes).at_least(:twice)
end
end
@ -70,21 +70,21 @@ RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do
before do
create(:stat, user: user_with_stats, year: previous_year, month: 1)
allow(YearlyDigests::CalculatingJob).to receive(:perform_later)
allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
allow(Users::Digests::CalculatingJob).to receive(:perform_later)
allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
end
it 'does not schedule jobs for user without stats' do
subject
expect(YearlyDigests::CalculatingJob).not_to have_received(:perform_later)
expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later)
.with(user_without_stats.id, anything)
end
it 'schedules jobs for user with stats' do
subject
expect(YearlyDigests::CalculatingJob).to have_received(:perform_later)
expect(Users::Digests::CalculatingJob).to have_received(:perform_later)
.with(user_with_stats.id, previous_year)
end
end
@ -95,14 +95,14 @@ RSpec.describe YearlyDigests::YearEndSchedulingJob, type: :job do
before do
create(:stat, user: user_current_year_only, year: Time.current.year, month: 1)
allow(YearlyDigests::CalculatingJob).to receive(:perform_later)
allow(YearlyDigests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
allow(Users::Digests::CalculatingJob).to receive(:perform_later)
allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil))
end
it 'does not schedule jobs for that user' do
subject
expect(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)
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'
RSpec.describe YearlyDigest, type: :model do
RSpec.describe Users::Digest, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
@ -13,22 +13,22 @@ RSpec.describe YearlyDigest, type: :model do
describe 'uniqueness of year within scope' do
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
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.errors[:year]).to include('has already been taken')
end
it 'allows same year for different period types' do
monthly = build(:yearly_digest, user: user, year: 2024, period_type: :monthly)
monthly = build(:users_digest, user: user, year: 2024, period_type: :monthly)
expect(monthly).to be_valid
end
it 'allows same year for different users' do
other_user = create(:user)
other_digest = build(: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
end
end
@ -41,14 +41,14 @@ RSpec.describe YearlyDigest, type: :model do
describe 'callbacks' do
describe 'before_create :generate_sharing_uuid' do
it 'generates a sharing_uuid if not present' do
digest = build(:yearly_digest, sharing_uuid: nil)
digest = build(:users_digest, sharing_uuid: nil)
digest.save!
expect(digest.sharing_uuid).to be_present
end
it 'does not overwrite existing sharing_uuid' do
existing_uuid = SecureRandom.uuid
digest = build(:yearly_digest, sharing_uuid: existing_uuid)
digest = build(:users_digest, sharing_uuid: existing_uuid)
digest.save!
expect(digest.sharing_uuid).to eq(existing_uuid)
end
@ -57,7 +57,7 @@ RSpec.describe YearlyDigest, type: :model do
describe 'helper methods' do
let(:user) { create(:user) }
let(:digest) { create(:yearly_digest, user: user) }
let(:digest) { create(:users_digest, user: user) }
describe '#countries_count' do
it 'returns count of countries from toponyms' do
@ -133,7 +133,7 @@ RSpec.describe YearlyDigest, type: :model do
end
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
expect(digest.yoy_distance_change).to be_nil
@ -190,7 +190,7 @@ RSpec.describe YearlyDigest, type: :model do
describe 'sharing settings' do
let(:user) { create(:user) }
let(:digest) { create(:yearly_digest, user: user) }
let(:digest) { create(:users_digest, user: user) }
describe '#sharing_enabled?' do
context 'when sharing_settings is nil' do
@ -408,7 +408,7 @@ RSpec.describe YearlyDigest, type: :model do
describe 'DistanceConvertible' do
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
it 'converts distance to kilometers' do

View file

@ -2,15 +2,15 @@
require 'rails_helper'
RSpec.describe 'Shared::YearlyDigests', type: :request do
RSpec.describe 'Shared::Digests', type: :request do
context 'public sharing' do
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
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.body).to include('Year in Review')
@ -18,7 +18,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end
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('Distance traveled')
@ -28,7 +28,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
context 'with invalid sharing UUID' 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(flash[:alert]).to eq('Shared digest not found or no longer available')
@ -36,10 +36,10 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end
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
get shared_yearly_digest_url(digest.sharing_uuid)
get shared_users_digest_url(digest.sharing_uuid)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq('Shared digest not found or no longer available')
@ -47,10 +47,10 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end
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
get shared_yearly_digest_url(digest.sharing_uuid)
get shared_users_digest_url(digest.sharing_uuid)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to eq('Shared digest not found or no longer available')
@ -58,15 +58,15 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end
end
describe 'PATCH /yearly_digests/:year/sharing' do
describe 'PATCH /digests/:year/sharing' 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 }
context 'enabling sharing' 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' },
as: :json
@ -83,7 +83,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end
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' },
as: :json
@ -94,10 +94,10 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
end
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
patch sharing_yearly_digest_path(year: 2023),
patch sharing_users_digest_path(year: 2023),
params: { enabled: '0' },
as: :json
@ -114,7 +114,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
context 'when digest does not exist' do
it 'returns not found' do
patch sharing_yearly_digest_path(year: 2020),
patch sharing_users_digest_path(year: 2020),
params: { enabled: '1' },
as: :json
@ -125,7 +125,7 @@ RSpec.describe 'Shared::YearlyDigests', type: :request do
context 'when user is not signed in' do
it 'returns unauthorized' do
patch sharing_yearly_digest_path(year: 2024),
patch sharing_users_digest_path(year: 2024),
params: { enabled: '1' },
as: :json

View file

@ -2,11 +2,11 @@
require 'rails_helper'
RSpec.describe '/yearly_digests', type: :request do
RSpec.describe '/digests', type: :request do
context 'when user is not signed in' do
describe 'GET /index' do
it 'redirects to the sign in page' do
get yearly_digests_url
get users_digests_url
expect(response.status).to eq(302)
end
@ -14,7 +14,7 @@ RSpec.describe '/yearly_digests', type: :request do
describe 'GET /show' 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)
end
@ -22,7 +22,7 @@ RSpec.describe '/yearly_digests', type: :request do
describe 'POST /create' 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)
end
@ -36,46 +36,46 @@ RSpec.describe '/yearly_digests', type: :request do
describe 'GET /index' do
it 'renders a successful response' do
get yearly_digests_url
get users_digests_url
expect(response.status).to eq(200)
end
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')
end
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')
end
end
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
get yearly_digest_url(year: 2024)
get users_digest_url(year: 2024)
expect(response.status).to eq(200)
end
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('Distance Traveled')
end
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')
end
end
@ -86,39 +86,39 @@ RSpec.describe '/yearly_digests', type: :request do
create(:stat, user:, year: 2024, month: 1)
end
it 'enqueues YearlyDigests::CalculatingJob' do
post yearly_digests_url, params: { year: 2024 }
it 'enqueues Users::Digests::CalculatingJob' do
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
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')
end
end
context 'with invalid year' 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')
end
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')
end
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')
end
end
@ -130,7 +130,7 @@ RSpec.describe '/yearly_digests', type: :request do
end
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(flash[:notice]).to eq('Your account is not active.')

View file

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

View file

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

View file

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

View file

@ -25,12 +25,12 @@ RSpec.describe Users::SafeSettings do
immich_api_key: nil,
photoprism_url: nil,
photoprism_api_key: nil,
maps: { "distance_unit" => "km" },
maps: { 'distance_unit' => 'km' },
distance_unit: 'km',
visits_suggestions_enabled: true,
speed_color_scale: nil,
fog_of_war_threshold: nil,
enabled_map_layers: ['Routes', 'Heatmap'],
enabled_map_layers: %w[Routes Heatmap],
maps_maplibre_style: 'light'
}
)
@ -56,7 +56,7 @@ RSpec.describe Users::SafeSettings do
'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false,
'enabled_map_layers' => ['Points', 'Routes', 'Areas', 'Photos']
'enabled_map_layers' => %w[Points Routes Areas Photos]
}
end
let(:safe_settings) { described_class.new(settings) }
@ -64,24 +64,25 @@ RSpec.describe Users::SafeSettings do
it 'returns custom configuration' do
expect(safe_settings.settings).to eq(
{
"fog_of_war_meters" => 100,
"meters_between_routes" => 1000,
"preferred_map_layer" => "Satellite",
"speed_colored_routes" => true,
"points_rendering_mode" => "simplified",
"minutes_between_routes" => 60,
"time_threshold_minutes" => 45,
"merge_threshold_minutes" => 20,
"live_map_enabled" => false,
"route_opacity" => 80,
"immich_url" => "https://immich.example.com",
"immich_api_key" => "immich-key",
"photoprism_url" => "https://photoprism.example.com",
"photoprism_api_key" => "photoprism-key",
"maps" => { "name" => "custom", "url" => "https://custom.example.com" },
"visits_suggestions_enabled" => false,
"enabled_map_layers" => ['Points', 'Routes', 'Areas', 'Photos'],
"maps_maplibre_style" => "light"
'fog_of_war_meters' => 100,
'meters_between_routes' => 1000,
'preferred_map_layer' => 'Satellite',
'speed_colored_routes' => true,
'points_rendering_mode' => 'simplified',
'minutes_between_routes' => 60,
'time_threshold_minutes' => 45,
'merge_threshold_minutes' => 20,
'live_map_enabled' => false,
'route_opacity' => 80,
'immich_url' => 'https://immich.example.com',
'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false,
'enabled_map_layers' => %w[Points Routes Areas Photos],
'maps_maplibre_style' => 'light',
'digest_emails_enabled' => true
}
)
end
@ -91,24 +92,24 @@ RSpec.describe Users::SafeSettings do
{
fog_of_war_meters: 100,
meters_between_routes: 1000,
preferred_map_layer: "Satellite",
preferred_map_layer: 'Satellite',
speed_colored_routes: true,
points_rendering_mode: "simplified",
points_rendering_mode: 'simplified',
minutes_between_routes: 60,
time_threshold_minutes: 45,
merge_threshold_minutes: 20,
live_map_enabled: false,
route_opacity: 80,
immich_url: "https://immich.example.com",
immich_api_key: "immich-key",
photoprism_url: "https://photoprism.example.com",
photoprism_api_key: "photoprism-key",
maps: { "name" => "custom", "url" => "https://custom.example.com" },
immich_url: 'https://immich.example.com',
immich_api_key: 'immich-key',
photoprism_url: 'https://photoprism.example.com',
photoprism_api_key: 'photoprism-key',
maps: { 'name' => 'custom', 'url' => 'https://custom.example.com' },
distance_unit: nil,
visits_suggestions_enabled: false,
speed_color_scale: 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'
}
)
@ -137,9 +138,9 @@ RSpec.describe Users::SafeSettings do
expect(safe_settings.immich_api_key).to be_nil
expect(safe_settings.photoprism_url).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.enabled_map_layers).to eq(['Routes', 'Heatmap'])
expect(safe_settings.enabled_map_layers).to eq(%w[Routes Heatmap])
end
end