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