diff --git a/MONTHLY_DIGEST_PLAN.md b/MONTHLY_DIGEST_PLAN.md new file mode 100644 index 00000000..a2b60d28 --- /dev/null +++ b/MONTHLY_DIGEST_PLAN.md @@ -0,0 +1,1728 @@ +# Monthly Digest Feature - Implementation Plan + +## Overview +Implement a monthly digest email feature similar to Google Timeline's monthly recaps. Users will receive an automated email on the 1st of each month summarizing their location data from the previous month. + +## Feature Scope + +### What We're Including (MVP) +- ✅ Monthly overview with stats (countries, cities, places visited) +- ✅ Distance statistics (total distance, daily breakdown) +- ✅ Top visited cities (by point count) +- ✅ Visited places (from visits/areas) +- ✅ Trips taken during the month +- ✅ All-time statistics +- ✅ Lucide icons for visual enhancement (no external images) +- ✅ Send to all active/trial users on 1st of month regardless of activity + +### What We're Skipping (For Now) +- ❌ Walking vs driving distinction (no activity type data) +- ❌ City/place images (complexity, API dependencies) +- ❌ Static map image in email (to be implemented later with custom solution) +- ❌ Activity-based sending (send regardless of previous month activity) + +## Technical Architecture + +### Design Principles for Extensibility +This implementation is designed to support both **monthly** and **yearly** digests with minimal code duplication: + +1. **Abstract base service**: Core digest logic separated from time period specifics +2. **Configurable time ranges**: Services accept flexible date ranges (month, year, custom) +3. **Reusable query objects**: Data queries parameterized by date range, not hardcoded to months +4. **Template partials**: Shared email card components that work for any time period +5. **Polymorphic scheduling**: Job structure supports multiple digest frequencies + +### Available Data Sources +- `stats` table: Monthly distance, daily_distance, toponyms, h3_hex_ids +- `points` table: Coordinates, timestamps, city, country_name +- `trips` table: User trips with dates, distance, visited_countries, photo_previews +- `visits` table: Area visits with duration +- `places` table: Named places user has visited +- `countries` table: Country geometry data + +## Implementation Plan + +### 📋 Implementation Phases Overview + +The implementation is split into **5 sequential phases** that can be completed incrementally. Each phase has clear deliverables and can be tested independently. + +| Phase | Focus Area | Duration | Dependencies | +|-------|-----------|----------|--------------| +| **Phase 1** | Foundation & Settings | 0.5-1 day | None | +| **Phase 2** | Data Aggregation | 2-3 days | Phase 1 | +| **Phase 3** | Email Design | 3-4 days | Phase 2 | +| **Phase 4** | Scheduling & Jobs | 1 day | Phase 3 | +| **Phase 5** | Preview & Polish | 1-2 days | Phase 4 | + +**Total Estimate**: 8-12 days for complete MVP + +--- + +### Phase 1: Foundation & Settings (0.5-1 day) ✅ COMPLETED + +**Goal**: Set up basic infrastructure for digest system + +#### Deliverables +- [x] User settings structure for digest preferences +- [x] Base mailer class created +- [x] Route structure defined +- [x] Basic specs for settings + +#### Tasks + +##### 1.1 User Settings Model +**File**: `app/models/user.rb` + +Add helper methods for digest preferences: +```ruby +# app/models/user.rb +class User < ApplicationRecord + # ... existing code ... + + def digest_enabled?(period: :monthly) + settings.dig('digest_preferences', period.to_s, 'enabled') || false + end + + def enable_digest!(period: :monthly) + prefs = settings['digest_preferences'] || {} + prefs[period.to_s] ||= {} + prefs[period.to_s]['enabled'] = true + update!(settings: settings.merge('digest_preferences' => prefs)) + end + + def disable_digest!(period: :monthly) + prefs = settings['digest_preferences'] || {} + prefs[period.to_s] ||= {} + prefs[period.to_s]['enabled'] = false + update!(settings: settings.merge('digest_preferences' => prefs)) + end + + def digest_last_sent_at(period: :monthly) + timestamp = settings.dig('digest_preferences', period.to_s, 'last_sent_at') + Time.zone.parse(timestamp) if timestamp.present? + rescue ArgumentError + nil + end +end +``` + +**Default settings structure**: +```ruby +user.settings = { + 'digest_preferences' => { + 'monthly' => { + 'enabled' => true, # Enabled by default for new users + 'last_sent_at' => nil + }, + 'yearly' => { + 'enabled' => true, # Future use + 'last_sent_at' => nil + } + } +} +``` + +##### 1.2 Mailer Skeleton +**File**: `app/mailers/digest_mailer.rb` +```ruby +class DigestMailer < ApplicationMailer + default from: 'hi@dawarich.app' + + def monthly_digest(user, year, month) + @user = user + @year = year + @month = month + @period_type = :monthly + @digest_data = {} # Will be populated in Phase 2 + + mail( + to: user.email, + subject: "#{Date::MONTHNAMES[month]} #{year} - Your Location Recap" + ) + end + + # Future: yearly_digest method + # def yearly_digest(user, year) + # @user = user + # @year = year + # @period_type = :yearly + # @digest_data = Digests::Calculator.new(user, period: :yearly, year: year).call + # + # mail( + # to: user.email, + # subject: "#{year} Year in Review - Your Memories Recap" + # ) + # end +end +``` + +##### 1.3 Routes +**File**: `config/routes.rb` +```ruby +namespace :digests do + get 'preview/:period/:year(/:month)', to: 'digests#preview', as: :preview + post 'send_test/:period', to: 'digests#send_test', as: :send_test +end + +# Examples: +# /digests/preview/monthly/2024/12 +# /digests/preview/yearly/2024 +``` + +##### 1.4 Placeholder Template +**File**: `app/views/digest_mailer/monthly_digest.html.erb` +```erb + + + + + + + +

Monthly Digest Placeholder

+

Hello <%= @user.email %>

+

This is a placeholder for <%= Date::MONTHNAMES[@month] %> <%= @year %>

+ + + +``` + +#### Testing Phase 1 +- [x] Test user settings methods (`digest_enabled?`, `enable_digest!`, etc.) +- [x] Test mailer can be called without errors +- [x] Test routes are accessible +- [x] Send test email with placeholder content + +#### Acceptance Criteria +- ✅ Users can enable/disable digest preferences +- ✅ Mailer sends placeholder email successfully +- ✅ Routes are defined and accessible +- ✅ Settings persist in database + +#### Implementation Summary +**Files Created/Modified:** +- `app/models/user.rb` - Added 4 digest preference methods +- `app/mailers/digest_mailer.rb` - Created with `monthly_digest` method +- `config/routes.rb` - Added digest preview and test routes +- `app/views/digest_mailer/monthly_digest.html.erb` - Placeholder template +- `spec/models/user_spec.rb` - Added 9 comprehensive test cases + +**Test Results:** All 9 specs passing ✅ +**Manual Test:** Successfully sent placeholder email ✅ + +--- + +### Phase 2: Data Aggregation Services (2-3 days) ✅ COMPLETED + +**Goal**: Build all data query services that power the digest + +#### Deliverables +- [x] Base calculator service +- [x] All query objects implemented +- [x] Comprehensive test coverage +- [x] Data validation and edge case handling + +#### Tasks + +##### 2.1 Main Calculator Service +**File**: `app/services/digests/calculator.rb` +```ruby +class Digests::Calculator + def initialize(user, period:, year:, month: nil) + @user = user + @period = period # :monthly or :yearly + @year = year + @month = month + @date_range = build_date_range + end + + def call + { + period_type: @period, + year: @year, + month: @month, + period_label: period_label, + overview: overview_data, + distance_stats: distance_stats, + top_cities: top_cities, + visited_places: visited_places, + trips: trips_data, + all_time_stats: all_time_stats + } + rescue StandardError => e + Rails.logger.error("Digest calculation failed: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + nil + end + + private + + def build_date_range + case @period + when :monthly + start_date = Date.new(@year, @month, 1).beginning_of_day + end_date = start_date.end_of_month.end_of_day + start_date..end_date + when :yearly + start_date = Date.new(@year, 1, 1).beginning_of_day + end_date = start_date.end_of_year.end_of_day + start_date..end_date + end + end + + def period_label + case @period + when :monthly + "#{Date::MONTHNAMES[@month]} #{@year}" + when :yearly + "#{@year}" + end + end + + def overview_data + Queries::Digests::Overview.new(@user, @date_range).call + end + + def distance_stats + Queries::Digests::Distance.new(@user, @date_range).call + end + + def top_cities + Queries::Digests::Cities.new(@user, @date_range).call + end + + def visited_places + Queries::Digests::Places.new(@user, @date_range).call + end + + def trips_data + Queries::Digests::Trips.new(@user, @date_range).call + end + + def all_time_stats + Queries::Digests::AllTime.new(@user).call + end +end +``` + +##### 2.2 Query: Overview +**File**: `app/services/digests/queries/overview.rb` +```ruby +ests::Queries::Digests::Overview + def initialize(user, date_range) + @user = user + @date_range = date_range + @start_timestamp = date_range.begin.to_i + @end_timestamp = date_range.end.to_i + end + + def call + { + countries_count: count_countries, + cities_count: count_cities, + places_count: count_places, + points_count: count_points + } + end + + private + + def count_countries + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .where.not(country_name: nil) + .distinct + .count(:country_name) + end + + def count_cities + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .where.not(city: nil) + .distinct + .count(:city) + end + + def count_places + @user.visits + .joins(:area) + .where(started_at: @date_range) + .distinct + .count('areas.id') + end + + def count_points + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .count + end +end +``` + +##### 2.3 Query: Distance +**File**: `app/services/digests/queries/distance.rb` +```ruby +ests::Queries::Digests::Distance + def initialize(user, date_range) + @user = user + @date_range = date_range + @start_timestamp = date_range.begin.to_i + @end_timestamp = date_range.end.to_i + end + + def call + points = fetch_points + + { + total_distance_km: calculate_total_distance(points), + daily_average_km: calculate_daily_average(points), + max_distance_day: find_max_distance_day(points) + } + end + + private + + def fetch_points + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .order(timestamp: :asc) + end + + def calculate_total_distance(points) + return 0 if points.empty? + + total = 0 + points.each_cons(2) do |p1, p2| + total += Geocoder::Calculations.distance_between( + [p1.latitude, p1.longitude], + [p2.latitude, p2.longitude], + units: :km + ) + end + total.round(2) + end + + def calculate_daily_average(points) + total = calculate_total_distance(points) + days = (@date_range.end.to_date - @date_range.begin.to_date).to_i + 1 + (total / days).round(2) + rescue ZeroDivisionError + 0 + end + + def find_max_distance_day(points) + # Group by day and calculate distance for each day + daily_distances = points.group_by { |p| Time.at(p.timestamp).to_date } + .transform_values { |day_points| calculate_total_distance(day_points) } + + max_day = daily_distances.max_by { |_date, distance| distance } + max_day ? { date: max_day[0], distance_km: max_day[1] } : nil + end +end +``` + +##### 2.4 Query: Cities +**File**: `app/services/digests/queries/cities.rb` +```ruby +ests::Queries::Digests::Cities + def initialize(user, date_range, limit: 5) + @user = user + @date_range = date_range + @limit = limit + @start_timestamp = date_range.begin.to_i + @end_timestamp = date_range.end.to_i + end + + def call + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .where.not(city: nil) + .group(:city) + .count + .sort_by { |_city, count| -count } + .first(@limit) + .map { |city, count| { name: city, visits: count } } + end +end +``` + +##### 2.5 Query: Places +**File**: `app/services/digests/queries/places.rb` +```ruby +ests::Queries::Digests::Places + def initialize(user, date_range, limit: 3) + @user = user + @date_range = date_range + @limit = limit + end + + def call + @user.visits + .joins(:area) + .where(started_at: @date_range) + .select('visits.*, areas.name as area_name, EXTRACT(EPOCH FROM (visits.ended_at - visits.started_at)) as duration_seconds') + .order('duration_seconds DESC') + .limit(@limit) + .map do |visit| + { + name: visit.area_name, + duration_hours: (visit.duration_seconds / 3600.0).round(1), + started_at: visit.started_at, + ended_at: visit.ended_at + } + end + end +end +``` + +##### 2.6 Query: Trips +**File**: `app/services/digests/queries/trips.rb` +```ruby +ests::Queries::Digests::Trips + def initialize(user, date_range) + @user = user + @date_range = date_range + end + + def call + @user.trips + .where('started_at <= ? AND ended_at >= ?', @date_range.end, @date_range.begin) + .order(started_at: :desc) + .map do |trip| + { + id: trip.id, + name: trip.name, + started_at: trip.started_at, + ended_at: trip.ended_at, + distance_km: trip.distance || 0, + countries: trip.visited_countries || [], + photo_previews: trip.photo_previews.first(3) + } + end + end +end +``` + +##### 2.7 Query: All-Time Stats +**File**: `app/services/digests/queries/all_time.rb` +```ruby +ests::Queries::Digests::AllTime + def initialize(user) + @user = user + end + + def call + { + total_countries: @user.points.where.not(country_name: nil).distinct.count(:country_name), + total_cities: @user.points.where.not(city: nil).distinct.count(:city), + total_places: @user.visits.joins(:area).distinct.count('areas.id'), + total_distance_km: calculate_total_distance, + account_age_days: account_age_days, + first_point_date: first_point_date + } + end + + private + + def calculate_total_distance + # Use cached stat data if available, otherwise calculate + @user.stats.sum(:distance) || 0 + end + + def account_age_days + (Date.today - @user.created_at.to_date).to_i + end + + def first_point_date + first_point = @user.points.order(timestamp: :asc).first + first_point ? Time.at(first_point.timestamp).to_date : nil + end +end +``` + +#### Testing Phase 2 +- [x] Test each query with empty data (no points/trips/visits) +- [x] Test each query with sample data +- [x] Test calculator integration +- [x] Test error handling and edge cases +- [x] Performance test with large datasets + +**Specs created**: +- `spec/services/digests/calculator_spec.rb` - 11 examples +- `spec/services/digests/queries/overview_spec.rb` - 5 examples +- `spec/services/digests/queries/distance_spec.rb` - 4 examples +- `spec/services/digests/queries/cities_spec.rb` - 3 examples +- `spec/services/digests/queries/all_time_spec.rb` - 6 examples + +#### Acceptance Criteria +- ✅ All queries return correct data structures +- ✅ Calculator aggregates all queries successfully +- ✅ Handles edge cases (no data, partial data) +- ✅ All tests passing (29 examples, 0 failures) +- ✅ No N+1 queries + +#### Implementation Summary +**Files Created:** +- `app/services/digests/calculator.rb` - Main calculator service +- `app/services/digests/queries/overview.rb` - Countries/cities/places counts +- `app/services/digests/queries/distance.rb` - Distance calculations +- `app/services/digests/queries/cities.rb` - Top cities ranking +- `app/services/digests/queries/places.rb` - Visited places +- `app/services/digests/queries/trips.rb` - Trip data +- `app/services/digests/queries/all_time.rb` - Lifetime statistics +- 5 comprehensive spec files with 29 test cases + +**Files Modified:** +- `app/mailers/digest_mailer.rb` - Integrated Calculator + +**Test Results:** All 29 specs passing ✅ +**Integration Test:** Successfully executed with real user data ✅ + +--- + +### Phase 3: Email Template & Design (3-4 days) + +**Goal**: Create beautiful, responsive email template with all cards + +#### Deliverables +- [ ] Complete email HTML/CSS +- [ ] All card partials +- [ ] Lucide icons integration +- [ ] Email client testing (Gmail, Outlook, Apple Mail) +- [ ] Empty state handling + +#### Tasks + +##### 3.1 Email Layout +**File**: `app/views/layouts/digest_mailer.html.erb` +```erb + + + + + + + + + <%= yield %> + + +``` + +##### 3.2 Main Email Template +**File**: `app/views/digest_mailer/monthly_digest.html.erb` +```erb +
+ +
+

Your <%= @digest_data[:period_label] %> Recap

+

Here's where you've been and what you've explored

+
+ + +
+ +

+ Hi <%= @user.email.split('@').first.capitalize %>, +

+

+ Your monthly location recap is ready! Here's a summary of your travels in <%= @digest_data[:period_label] %>. +

+ + +
+ <%= link_to 'View on Dawarich', + root_url, + class: 'btn-primary', + style: 'background-color: #667eea; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;' %> +
+ + + <%= render 'digest_mailer/cards/overview', data: @digest_data[:overview] %> + <%= render 'digest_mailer/cards/distance', data: @digest_data[:distance_stats] %> + <%= render 'digest_mailer/cards/cities', data: @digest_data[:top_cities] %> + <%= render 'digest_mailer/cards/places', data: @digest_data[:visited_places] %> + <%= render 'digest_mailer/cards/trips', data: @digest_data[:trips] %> + <%= render 'digest_mailer/cards/all_time', data: @digest_data[:all_time_stats] %> +
+ + + +
+``` + +##### 3.3 Card Partial Templates + +**File**: `app/views/digest_mailer/cards/_overview.html.erb` +```erb +<% if data && (data[:countries_count] > 0 || data[:cities_count] > 0) %> +
+
+ + + + + + +

Overview

+
+ +
+
+ <%= data[:countries_count] %> + <%= 'Country'.pluralize(data[:countries_count]) %> +
+
+ <%= data[:cities_count] %> + <%= 'City'.pluralize(data[:cities_count]) %> +
+
+ <%= data[:places_count] %> + <%= 'Place'.pluralize(data[:places_count]) %> +
+
+ <%= number_with_delimiter(data[:points_count]) %> + <%= 'Point'.pluralize(data[:points_count]) %> tracked +
+
+
+<% end %> +``` + +**File**: `app/views/digest_mailer/cards/_distance.html.erb` +```erb +<% if data && data[:total_distance_km] > 0 %> +
+
+ + + + + + +

Distance Traveled

+
+ +
+
+ <%= number_with_delimiter(data[:total_distance_km].round) %> + Total km +
+
+ <%= data[:daily_average_km].round %> + Daily average km +
+
+ + <% if data[:max_distance_day] %> +

+ Your longest day: <%= data[:max_distance_day][:distance_km].round %> km + on <%= data[:max_distance_day][:date].strftime('%B %d') %> +

+ <% end %> +
+<% end %> +``` + +**File**: `app/views/digest_mailer/cards/_cities.html.erb` +```erb +<% if data&.any? %> +
+
+ + + + + + + + + + + + + + +

Top Cities

+
+ +
+ <% data.each_with_index do |city, index| %> +
+ <%= index + 1 %>. <%= city[:name] %> + <%= number_with_delimiter(city[:visits]) %> <%= 'visit'.pluralize(city[:visits]) %> +
+ <% end %> +
+
+<% end %> +``` + +**File**: `app/views/digest_mailer/cards/_places.html.erb` +```erb +<% if data&.any? %> +
+
+ + + + + +

Visited Places

+
+ +
+ <% data.each do |place| %> +
+

<%= place[:name] %>

+

+ Spent <%= place[:duration_hours] %> hours • + <%= place[:started_at].strftime('%b %d') %> +

+
+ <% end %> +
+ + <%= link_to 'View all visits', visits_url, style: 'color: #667eea; text-decoration: none; font-weight: 600;' %> +
+<% end %> +``` + +**File**: `app/views/digest_mailer/cards/_trips.html.erb` +```erb +<% if data&.any? %> +
+
+ + + + +

Trips

+
+ +
+ <% data.each do |trip| %> +
+

+ <%= link_to trip[:name], trip_url(trip[:id]), style: 'color: #667eea; text-decoration: none;' %> +

+

+ <%= trip[:started_at].strftime('%b %d') %> - <%= trip[:ended_at].strftime('%b %d') %> • + <%= number_with_delimiter(trip[:distance_km].round) %> km +

+ <% if trip[:countries]&.any? %> +

+ <%= trip[:countries].join(', ') %> +

+ <% end %> +
+ <% end %> +
+ + <%= link_to 'View all trips', trips_url, style: 'color: #667eea; text-decoration: none; font-weight: 600;' %> +
+<% end %> +``` + +**File**: `app/views/digest_mailer/cards/_all_time.html.erb` +```erb +<% if data %> +
+
+ + + + + + + + + +

All-Time Stats

+
+ +
+
+ <%= data[:total_countries] %> + Total countries +
+
+ <%= data[:total_cities] %> + Total cities +
+
+ <%= data[:total_places] %> + Total places +
+
+ <%= number_with_delimiter(data[:total_distance_km].round) %> + Total km +
+
+ + <% if data[:first_point_date] %> +

+ Tracking since <%= data[:first_point_date].strftime('%B %Y') %> +

+ <% end %> +
+<% end %> +``` + +##### 3.4 Update Mailer to Use Calculator +**File**: `app/mailers/digest_mailer.rb` (update) +```ruby +class DigestMailer < ApplicationMailer + default from: 'no-reply@dawarich.app' + + def monthly_digest(user, year, month) + @user = user + @year = year + @month = month + @period_type = :monthly + @digest_data = Digests::Calculator.new(user, period: :monthly, year: year, month: month).call + + return if @digest_data.nil? # Don't send if calculation failed + + mail( + to: user.email, + subject: "#{Date::MONTHNAMES[month]} #{year} - Your Location Recap" + ) + end +end +``` + +#### Testing Phase 3 +- [ ] Preview email in browser (`/rails/mailers`) +- [ ] Test with empty/partial data +- [ ] Test in major email clients (Gmail, Outlook, Apple Mail) +- [ ] Test on mobile devices +- [ ] Verify all links work correctly +- [ ] Test with long city/place names +- [ ] Test with special characters in data + +**Tools for testing**: +- Rails mailer previews +- Litmus or Email on Acid (email client testing) +- SendGrid email preview + +#### Acceptance Criteria +✅ Email renders correctly in all major clients +✅ All cards display properly with real data +✅ Empty states handled gracefully +✅ Links navigate to correct pages +✅ Mobile responsive +✅ Accessible (screen readers, alt text) + +--- + +### Phase 4: Scheduling & Background Jobs (1 day) + +**Goal**: Implement job system for automated digest delivery + +#### Deliverables +- [ ] Delivery job +- [ ] Scheduling job +- [ ] Cron configuration +- [ ] Error handling and retries +- [ ] Monitoring/logging + +#### Tasks + +##### 3.1 Email Structure +1. **Header** + - Greeting (personalized with user name if available) + - Brief explanation of email + - CTA button: "View on Dawarich" + +2. **Card 1: Overview** + - Icon: Map pin or globe (Lucide) + - Countries visited: X + - Cities visited: X + - Places visited: X + - ~~Map image~~ (TODO: implement later) + - Link to month view in web app + +3. **Card 2: Distance Stats** + - Icon: Route/navigation (Lucide) + - Total distance traveled: X km/mi + - Daily average: X km/mi + - Bar chart or simple daily breakdown (HTML/CSS) + +4. **Card 3: Top Cities** + - Icon: Building/city (Lucide) + - List top 3-5 cities with point count + - Simple text list, no images + - Link to points/map view filtered by city + +5. **Card 4: Visited Places** + - Icon: MapPin (Lucide) + - 1-3 places with longest visit duration + - Place name, duration + - Link to `/visits` page + +6. **Card 5: Trips** + - Icon: Plane/luggage (Lucide) + - Trip name, dates, distance + - Countries visited in trip + - Use existing `photo_previews` if available + - Link to `/trips/:id` + +7. **Card 6: All-Time Stats** + - Icon: Trophy/award (Lucide) + - Total countries visited: X + - Total cities visited: X + - Total places visited: X + - Total distance traveled: X km/mi + +8. **Footer** + - Unsubscribe link + - Settings link + - Social links (if applicable) + +#### 3.2 Email Styling +**File**: `app/views/layouts/monthly_digest_mailer.html.erb` +- Inline CSS (email client compatibility) +- Mobile-responsive design +- Light/dark mode considerations +- Accessible color contrast + +##### 4.1 Delivery Job +**File**: `app/jobs/digests/delivery_job.rb` +```ruby +class Digests::DeliveryJob < ApplicationJob + queue_as :default + + def perform(user_id, period:, year:, month: nil) + user = User.find(user_id) + return unless digest_enabled?(user, period) + + case period + when :monthly + DigestMailer.monthly_digest(user, year, month).deliver_now + update_last_sent(user, :monthly) + when :yearly + DigestMailer.yearly_digest(user, year).deliver_now + update_last_sent(user, :yearly) + end + end + + private + + def digest_enabled?(user, period) + user.settings.dig('digest_preferences', period.to_s, 'enabled') + end + + def update_last_sent(user, period) + prefs = user.settings['digest_preferences'] || {} + prefs[period.to_s] ||= {} + prefs[period.to_s]['last_sent_at'] = Time.current.iso8601 + user.update!(settings: user.settings.merge('digest_preferences' => prefs)) + end +end +``` + +##### 4.2 Scheduling Job +**File**: `app/jobs/digests/scheduling/monthly_job.rb` +```ruby +class Digests::Scheduling::MonthlyJob < ApplicationJob + queue_as :default + + def perform + year = 1.month.ago.year + month = 1.month.ago.month + + User.find_in_batches(batch_size: 100) do |users| + users.each do |user| + Digests::DeliveryJob.perform_later(user.id, period: :monthly, year: year, month: month) + end + end + end +end +``` + +**File**: `app/jobs/digests/scheduling/yearly_job.rb` (future) +```ruby +class Digests::Scheduling::YearlyJob < ApplicationJob + queue_as :default + + def perform + year = 1.year.ago.year + + User.find_in_batches(batch_size: 100) do |users| + users.each do |user| + Digests::DeliveryJob.perform_later(user.id, period: :yearly, year: year) + end + end + end +end +``` + +##### 4.3 Cron Configuration +**File**: `config/schedule.yml` (or use existing cron setup) +```yaml +monthly_digest: + cron: "0 9 1 * *" # 9 AM on the 1st of every month + class: "Digests::Scheduling::MonthlyJob" + +# Future: +# yearly_digest: +# cron: "0 9 1 1 *" # 9 AM on January 1st +# class: "Digests::Scheduling::YearlyJob" +``` + +##### 4.4 Error Handling & Logging +Add to jobs for production monitoring: +```ruby +# app/jobs/digests/delivery_job.rb +class Digests::DeliveryJob < ApplicationJob + queue_as :default + retry_on StandardError, wait: 5.minutes, attempts: 3 + + def perform(user_id, period:, year:, month: nil) + user = User.find(user_id) + return unless digest_enabled?(user, period) + + Rails.logger.info("Sending #{period} digest to user #{user.id} for #{year}/#{month}") + + case period + when :monthly + DigestMailer.monthly_digest(user, year, month).deliver_now + update_last_sent(user, :monthly) + Rails.logger.info("Successfully sent monthly digest to user #{user.id}") + when :yearly + DigestMailer.yearly_digest(user, year).deliver_now + update_last_sent(user, :yearly) + Rails.logger.info("Successfully sent yearly digest to user #{user.id}") + end + rescue StandardError => e + Rails.logger.error("Failed to send #{period} digest to user #{user_id}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + raise # Re-raise for retry logic + end + + # ... rest of the code +end +``` + +#### Testing Phase 4 +- [ ] Test delivery job with real user +- [ ] Test scheduling job enqueues correctly +- [ ] Test error handling and retries +- [ ] Test with disabled users +- [ ] Test with missing/invalid data +- [ ] Monitor Sidekiq dashboard + +**Manual testing**: +```ruby +# In Rails console +user = User.first +Digests::DeliveryJob.perform_now(user.id, period: :monthly, year: 2024, month: 12) + +# Test scheduling +Digests::Scheduling::MonthlyJob.perform_now +``` + +#### Acceptance Criteria +✅ Jobs enqueue and process successfully +✅ Emails delivered to users +✅ Error handling and retries work +✅ Logging captures important events +✅ Cron schedule configured correctly +✅ No duplicate emails sent + +--- + +### Phase 5: Web Preview & Settings (1-2 days) + +**Goal**: Allow users to preview digests and manage preferences + +#### Deliverables +- [ ] Preview controller and views +- [ ] Settings UI for digest preferences +- [ ] Send test digest functionality +- [ ] User documentation + +#### Tasks + +##### 5.1 Preview Controller +**File**: `app/controllers/digests_controller.rb` +```ruby +class DigestsController < ApplicationController + before_action :authenticate_user! + + def preview + @period = params[:period].to_sym + @year = params[:year].to_i + @month = params[:month]&.to_i + + case @period + when :monthly + @user = current_user + @digest_data = Digests::Calculator.new( + current_user, + period: :monthly, + year: @year, + month: @month + ).call + + if @digest_data.nil? + flash[:alert] = "Could not generate digest. Please ensure you have data for this period." + redirect_to settings_path + return + end + + render 'digest_mailer/monthly_digest', layout: 'mailer' + when :yearly + # Future implementation + flash[:info] = "Yearly digest coming soon!" + redirect_to settings_path + else + flash[:alert] = "Invalid digest period" + redirect_to settings_path + end + end + + def send_test + period = params[:period].to_sym + + case period + when :monthly + year = 1.month.ago.year + month = 1.month.ago.month + Digests::DeliveryJob.perform_later(current_user.id, period: :monthly, year: year, month: month) + flash[:notice] = "Test digest queued! Check your email in a few moments." + when :yearly + flash[:info] = "Yearly digest coming soon!" + else + flash[:alert] = "Invalid digest period" + end + + redirect_to settings_path + end +end +``` + +##### 5.2 Settings View Partial +**File**: `app/views/settings/_digest_preferences.html.erb` +```erb +
+
+

+ + + + + + + Digest Preferences +

+

+ Receive periodic recaps of your location data via email +

+ +
+ + +
+ + +
+ <%= link_to 'Preview Last Month', + digests_preview_path(period: 'monthly', year: 1.month.ago.year, month: 1.month.ago.month), + class: 'btn btn-sm btn-outline', + target: '_blank' %> + <%= button_to 'Send Test Email', + digests_send_test_path(period: 'monthly'), + method: :post, + class: 'btn btn-sm btn-primary', + data: { confirm: 'Send a test digest to your email?' } %> +
+
+ +
+ + +
+ +
+
+
+``` + +##### 5.3 Settings Controller Update +**File**: `app/controllers/settings_controller.rb` +```ruby +class SettingsController < ApplicationController + before_action :authenticate_user! + + def update_digest_preference + period = params[:period].to_sym + enabled = params[:enabled] == 'true' + + if enabled + current_user.enable_digest!(period) + message = "#{period.to_s.capitalize} digest enabled" + else + current_user.disable_digest!(period) + message = "#{period.to_s.capitalize} digest disabled" + end + + respond_to do |format| + format.json { render json: { success: true, message: message } } + format.html { redirect_to settings_path, notice: message } + end + end +end +``` + +##### 5.4 Routes Update +**File**: `config/routes.rb` +```ruby +# Add to existing routes +namespace :digests do + get 'preview/:period/:year(/:month)', to: 'digests#preview', as: :preview + post 'send_test/:period', to: 'digests#send_test', as: :send_test +end + +# Add to settings routes +resources :settings, only: [:index, :update] do + collection do + patch 'digest_preference', to: 'settings#update_digest_preference' + end +end +``` + +##### 5.5 Stimulus Controller (Optional, for toggle) +**File**: `app/javascript/controllers/settings_controller.js` +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + async toggleMonthlyDigest(event) { + const enabled = event.target.checked + + const response = await fetch('/settings/digest_preference', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content + }, + body: JSON.stringify({ + period: 'monthly', + enabled: enabled + }) + }) + + const data = await response.json() + + // Show toast notification (if you have a toast system) + console.log(data.message) + } +} +``` + +#### Testing Phase 5 +- [ ] Test preview with current month +- [ ] Test preview with historical months +- [ ] Test preview with no data +- [ ] Test send test email functionality +- [ ] Test toggle enable/disable +- [ ] Test settings persistence +- [ ] Test authorization (users can only see their own digests) + +#### Acceptance Criteria +✅ Users can preview any month's digest +✅ Test email sends successfully +✅ Settings save and persist correctly +✅ UI is intuitive and accessible +✅ Error states handled gracefully +✅ Authorization works correctly + +--- + +## Post-Implementation Checklist + +### Before Launch +- [ ] All phases complete and tested +- [ ] Email deliverability tested (check spam scores) +- [ ] Performance tested with large datasets +- [ ] Error monitoring configured (Sentry/Rollbar) +- [ ] Documentation updated +- [ ] User announcement prepared + +### Launch Day +- [ ] Enable cron job +- [ ] Monitor first batch of emails +- [ ] Check email delivery rates +- [ ] Monitor error logs +- [ ] Gather initial user feedback + +### Week 1 Monitoring +- [ ] Track email open rates +- [ ] Track click-through rates +- [ ] Monitor unsubscribe rate +- [ ] Review error logs +- [ ] Collect user feedback +- [ ] Performance optimization if needed + +--- + +## Rollback Plan + +If issues arise after launch: + +1. **Disable cron job immediately** + ```ruby + # Comment out in config/schedule.yml or disable in cron + ``` + +2. **Stop pending jobs** + ```ruby + # In Rails console + Sidekiq::Queue.new('default').clear + ``` + +3. **Disable digest for all users** (emergency) + ```ruby + User.find_each do |user| + user.disable_digest!(:monthly) + end + ``` + +4. **Investigate and fix issues** + +5. **Re-enable gradually** + - Test with small user group first + - Monitor carefully + - Expand to full user base + +## Database Changes + +### Migration 1: User Settings (Extensible Structure) +Store in existing `settings` JSONB column (preferred): +```ruby +user.settings = { + 'digest_preferences' => { + 'monthly' => { + 'enabled' => true, + 'last_sent_at' => '2024-01-01T09:00:00Z' + }, + 'yearly' => { + 'enabled' => false, # For future use + 'last_sent_at' => nil + } + # Future: 'weekly', 'quarterly', etc. + } +} +``` + +**Benefits**: +- No schema migration needed +- Easy to add yearly/quarterly digests later +- Per-period preferences and tracking +- Backward compatible (check for `digest_preferences` existence) + +## Testing Strategy + +### Unit Tests +- `spec/services/digests/calculator_spec.rb` - Test both monthly and yearly periods +- `spec/services/digests/queries/*_spec.rb` - Test with various date ranges +- `spec/mailers/digest_mailer_spec.rb` - Test both monthly and yearly emails + +### Integration Tests +- `spec/jobs/digests/delivery_job_spec.rb` - Test multiple periods +- `spec/jobs/digests/scheduling/monthly_job_spec.rb` +- `spec/jobs/digests/scheduling/yearly_job_spec.rb` (future) + +### Request Tests +- `spec/requests/digests_spec.rb` - Preview endpoint for all periods + +### Email Tests +- Test email rendering +- Test data aggregation +- Test user preference handling +- Test batch processing + +## Yearly Digest Extension Plan + +When implementing yearly digest (estimated **2-3 days** additional work): + +### What Changes: +1. **Mailer**: Add `yearly_digest` method to `DigestMailer` ✅ (structure already in place) +2. **Template**: Create `app/views/digest_mailer/yearly_digest.html.erb` + - Reuse existing card partials + - Adjust copy: "December 2024" → "2024 Year in Review" + - Add yearly-specific cards: + - Month-by-month breakdown (12 mini cards) + - Seasonal patterns (if applicable) + - Year-over-year comparison (if multiple years available) +3. **Scheduler**: Enable `Digests::Scheduling::YearlyJob` in cron ✅ (already defined) +4. **Settings UI**: Enable yearly toggle (currently grayed out) +5. **Calculator**: Already supports `period: :yearly` ✅ + +### What Stays the Same: +- All query objects (already use `date_range`) ✅ +- Job infrastructure (`Digests::DeliveryJob`) ✅ +- Settings structure (`digest_preferences.yearly`) ✅ +- Preview controller (already parameterized) ✅ + +### Yearly-Specific Data Queries: +- Monthly distance breakdown (12 data points) +- Busiest travel month +- Countries visited by month (timeline visualization) +- Total vs. average monthly stats + +**Reuse Percentage**: ~80% of code can be reused + +## Future Enhancements (Post-MVP) + +### Map Image Generation +**Idea**: Generate static map images server-side +- Use headless browser (Puppeteer/Playwright) to render Leaflet map +- Capture screenshot of map with hexagon overlay +- Store in S3 or temp storage +- Embed in email as attachment/inline image +- **TODO file**: Create separate plan for this feature + +### Activity Type Detection +- Add `activity_type` to points table +- Infer from velocity/speed patterns +- Show walking vs driving stats + +### Personalization +- Smart insights ("You visited 3 new countries this month!") +- Comparisons to previous months +- Streak tracking (consecutive months with travel) + +### Advanced Stats +- Time of day analysis (morning vs evening travel) +- Weekday vs weekend patterns +- Seasonal trends + +### User Preferences +- Choose digest frequency (weekly, monthly, quarterly, yearly) ✅ Structure ready +- Select day of month/year for delivery +- Customize which cards to include +- Language preferences + +### Additional Digest Frequencies +With the current extensible architecture, adding new frequencies is straightforward: + +**Weekly Digest**: +- Add `'weekly'` to `digest_preferences` +- Create `Digests::Scheduling::WeeklyJob` +- Template reuses same card partials +- Estimated effort: 1-2 days + +**Quarterly Digest**: +- Add `'quarterly'` to `digest_preferences` +- Create `Digests::Scheduling::QuarterlyJob` +- Template reuses same card partials +- Add quarter-specific insights (seasonal patterns) +- Estimated effort: 2 days + +## Timeline Estimate + +- **Phase 1**: 1-2 days (Email infrastructure) +- **Phase 2**: 2-3 days (Data aggregation) +- **Phase 3**: 3-4 days (Email template & cards) +- **Phase 4**: 1 day (Scheduling) +- **Phase 5**: 1-2 days (Preview & settings) + +**Total**: 8-12 days for complete MVP + +## Dependencies & Risks + +### Dependencies +- Existing `Stat` calculation must be reliable +- Sidekiq/Redis must be configured +- Email delivery service (SendGrid, Mailgun, etc.) +- Existing user settings system + +### Risks +- Email deliverability (spam filters) +- Large user base → batch processing performance +- Missing/incomplete data for some months +- Email client compatibility issues (Outlook, Gmail, etc.) + +### Mitigation +- Use established email service with good reputation +- Implement retry logic for failed deliveries +- Gracefully handle missing data in templates +- Test emails in major clients before launch +- Add monitoring/logging for delivery success rate + +## Success Metrics + +### Primary KPIs +- Email open rate > 30% +- Click-through rate > 10% +- Unsubscribe rate < 2% + +### Secondary Metrics +- User engagement after digest (web visits, data exploration) +- Feature awareness (do users discover features via digest?) +- Support tickets related to digest feature + +## Open Questions + +1. Should we send digest even if user has NO activity in previous month? + - **Decision**: Yes, send regardless (can show "No activity this month" state) + +2. What if stat hasn't been calculated for the month yet? + - **Option A**: Calculate on-demand before sending + - **Option B**: Skip digest and notify user + - **Recommended**: Option A with timeout/fallback + +3. Should preview require existing stat or calculate on-the-fly? + - **Recommended**: Calculate on-the-fly for flexibility + +4. Email preference granularity? + - **MVP**: Single toggle (enable/disable) + - **Future**: Choose frequency, day of month, card selection + +## Notes + +- This plan prioritizes MVP speed over perfection +- Focus on data quality and email deliverability +- Keep template simple and maintainable +- Consider email accessibility (screen readers, alt text) +- Plan for internationalization if needed (I18n) diff --git a/app/mailers/digest_mailer.rb b/app/mailers/digest_mailer.rb new file mode 100644 index 00000000..0df36114 --- /dev/null +++ b/app/mailers/digest_mailer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DigestMailer < ApplicationMailer + def monthly_digest(user, year, month) + @user = user + @year = year + @month = month + @period_type = :monthly + @digest_data = Digests::Calculator.new(user, period: :monthly, year: year, month: month).call + + return if @digest_data.nil? # Don't send if calculation failed + + mail( + to: user.email, + subject: "#{Date::MONTHNAMES[month]} #{year} - Your Location Recap" + ) + end + + # Future: yearly_digest method + # def yearly_digest(user, year) + # @user = user + # @year = year + # @period_type = :yearly + # @digest_data = Digests::Calculator.new(user, period: :yearly, year: year).call + # + # mail( + # to: user.email, + # subject: "#{year} Year in Review - Your Location Recap" + # ) + # end +end diff --git a/app/models/user.rb b/app/models/user.rb index 71269d64..31e936ad 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -145,6 +145,32 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength points.where.not(city: [nil, '']).distinct.pluck(:city).compact end + # Digest preferences methods + def digest_enabled?(period = :monthly) + settings.dig('digest_preferences', period.to_s, 'enabled') || false + end + + def enable_digest!(period = :monthly) + prefs = settings['digest_preferences'] || {} + prefs[period.to_s] ||= {} + prefs[period.to_s]['enabled'] = true + update!(settings: settings.merge('digest_preferences' => prefs)) + end + + def disable_digest!(period = :monthly) + prefs = settings['digest_preferences'] || {} + prefs[period.to_s] ||= {} + prefs[period.to_s]['enabled'] = false + update!(settings: settings.merge('digest_preferences' => prefs)) + end + + def digest_last_sent_at(period = :monthly) + timestamp = settings.dig('digest_preferences', period.to_s, 'last_sent_at') + Time.zone.parse(timestamp) if timestamp.present? + rescue ArgumentError + nil + end + private def create_api_key diff --git a/app/services/digests/calculator.rb b/app/services/digests/calculator.rb new file mode 100644 index 00000000..e4251445 --- /dev/null +++ b/app/services/digests/calculator.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Digests::Calculator + def initialize(user, period:, year:, month: nil) + @user = user + @period = period # :monthly or :yearly + @year = year + @month = month + @date_range = build_date_range + end + + def call + { + period_type: @period, + year: @year, + month: @month, + period_label: period_label, + overview: overview_data, + distance_stats: distance_stats, + top_cities: top_cities, + visited_places: visited_places, + trips: trips_data, + all_time_stats: all_time_stats + } + rescue StandardError => e + Rails.logger.error("Digest calculation failed: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + nil + end + + private + + def build_date_range + case @period + when :monthly + start_date = Date.new(@year, @month, 1).beginning_of_day + end_date = start_date.end_of_month.end_of_day + start_date..end_date + when :yearly + start_date = Date.new(@year, 1, 1).beginning_of_day + end_date = start_date.end_of_year.end_of_day + start_date..end_date + end + end + + def period_label + case @period + when :monthly + "#{Date::MONTHNAMES[@month]} #{@year}" + when :yearly + "#{@year}" + end + end + + def overview_data + Digests::Queries::Overview.new(@user, @date_range).call + end + + def distance_stats + Digests::Queries::Distance.new(@user, @date_range).call + end + + def top_cities + Digests::Queries::Cities.new(@user, @date_range).call + end + + def visited_places + Digests::Queries::Places.new(@user, @date_range).call + end + + def trips_data + Digests::Queries::Trips.new(@user, @date_range).call + end + + def all_time_stats + Digests::Queries::AllTime.new(@user).call + end +end diff --git a/app/services/digests/queries/all_time.rb b/app/services/digests/queries/all_time.rb new file mode 100644 index 00000000..b3f9b0bc --- /dev/null +++ b/app/services/digests/queries/all_time.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Digests::Queries::AllTime + def initialize(user) + @user = user + end + + def call + { + total_countries: @user.points.where.not(country_name: nil).distinct.count(:country_name), + total_cities: @user.points.where.not(city: nil).distinct.count(:city), + total_places: @user.visits.joins(:area).distinct.count('areas.id'), + total_distance_km: calculate_total_distance, + account_age_days: account_age_days, + first_point_date: first_point_date + } + end + + private + + def calculate_total_distance + # Use cached stat data if available, otherwise calculate + @user.stats.sum(:distance) || 0 + end + + def account_age_days + (Date.today - @user.created_at.to_date).to_i + end + + def first_point_date + first_point = @user.points.order(timestamp: :asc).first + first_point ? Time.at(first_point.timestamp).to_date : nil + end +end diff --git a/app/services/digests/queries/cities.rb b/app/services/digests/queries/cities.rb new file mode 100644 index 00000000..c6e9940e --- /dev/null +++ b/app/services/digests/queries/cities.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Digests::Queries::Cities + def initialize(user, date_range, limit: 5) + @user = user + @date_range = date_range + @limit = limit + @start_timestamp = date_range.begin.to_i + @end_timestamp = date_range.end.to_i + end + + def call + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .where.not(city: nil) + .group(:city) + .count + .sort_by { |_city, count| -count } + .first(@limit) + .map { |city, count| { name: city, visits: count } } + end +end diff --git a/app/services/digests/queries/distance.rb b/app/services/digests/queries/distance.rb new file mode 100644 index 00000000..b17e274e --- /dev/null +++ b/app/services/digests/queries/distance.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Digests::Queries::Distance + def initialize(user, date_range) + @user = user + @date_range = date_range + @start_timestamp = date_range.begin.to_i + @end_timestamp = date_range.end.to_i + end + + def call + points = fetch_points + + { + total_distance_km: calculate_total_distance(points), + daily_average_km: calculate_daily_average(points), + max_distance_day: find_max_distance_day(points) + } + end + + private + + def fetch_points + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .order(timestamp: :asc) + end + + def calculate_total_distance(points) + return 0 if points.empty? + + total = 0 + points.each_cons(2) do |p1, p2| + total += Geocoder::Calculations.distance_between( + [p1.latitude, p1.longitude], + [p2.latitude, p2.longitude], + units: :km + ) + end + total.round(2) + end + + def calculate_daily_average(points) + total = calculate_total_distance(points) + days = (@date_range.end.to_date - @date_range.begin.to_date).to_i + 1 + (total / days).round(2) + rescue ZeroDivisionError + 0 + end + + def find_max_distance_day(points) + # Group by day and calculate distance for each day + daily_distances = points.group_by { |p| Time.at(p.timestamp).to_date } + .transform_values { |day_points| calculate_total_distance(day_points) } + + max_day = daily_distances.max_by { |_date, distance| distance } + max_day ? { date: max_day[0], distance_km: max_day[1] } : nil + end +end diff --git a/app/services/digests/queries/overview.rb b/app/services/digests/queries/overview.rb new file mode 100644 index 00000000..c29ae536 --- /dev/null +++ b/app/services/digests/queries/overview.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Digests::Queries::Overview + def initialize(user, date_range) + @user = user + @date_range = date_range + @start_timestamp = date_range.begin.to_i + @end_timestamp = date_range.end.to_i + end + + def call + { + countries_count: count_countries, + cities_count: count_cities, + places_count: count_places, + points_count: count_points + } + end + + private + + def count_countries + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .where.not(country_name: nil) + .distinct + .count(:country_name) + end + + def count_cities + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .where.not(city: nil) + .distinct + .count(:city) + end + + def count_places + @user.visits + .joins(:area) + .where(started_at: @date_range) + .distinct + .count('areas.id') + end + + def count_points + @user.points + .where(timestamp: @start_timestamp..@end_timestamp) + .count + end +end diff --git a/app/services/digests/queries/places.rb b/app/services/digests/queries/places.rb new file mode 100644 index 00000000..aff97df8 --- /dev/null +++ b/app/services/digests/queries/places.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Digests::Queries::Places + def initialize(user, date_range, limit: 3) + @user = user + @date_range = date_range + @limit = limit + end + + def call + @user.visits + .joins(:area) + .where(started_at: @date_range) + .select('visits.*, areas.name as area_name, EXTRACT(EPOCH FROM (visits.ended_at - visits.started_at)) as duration_seconds') + .order('duration_seconds DESC') + .limit(@limit) + .map do |visit| + { + name: visit.area_name, + duration_hours: (visit.duration_seconds / 3600.0).round(1), + started_at: visit.started_at, + ended_at: visit.ended_at + } + end + end +end diff --git a/app/services/digests/queries/trips.rb b/app/services/digests/queries/trips.rb new file mode 100644 index 00000000..2492b2ce --- /dev/null +++ b/app/services/digests/queries/trips.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Digests::Queries::Trips + def initialize(user, date_range) + @user = user + @date_range = date_range + end + + def call + @user.trips + .where('started_at <= ? AND ended_at >= ?', @date_range.end, @date_range.begin) + .order(started_at: :desc) + .map do |trip| + { + id: trip.id, + name: trip.name, + started_at: trip.started_at, + ended_at: trip.ended_at, + distance_km: trip.distance || 0, + countries: trip.visited_countries || [], + photo_previews: trip.photo_previews.first(3) + } + end + end +end diff --git a/app/views/digest_mailer/monthly_digest.html.erb b/app/views/digest_mailer/monthly_digest.html.erb new file mode 100644 index 00000000..776ddf79 --- /dev/null +++ b/app/views/digest_mailer/monthly_digest.html.erb @@ -0,0 +1,13 @@ + + + + + + + +

Monthly Digest Placeholder

+

Hello <%= @user.email %>

+

This is a placeholder for <%= Date::MONTHNAMES[@month] %> <%= @year %>

+ + + diff --git a/config/routes.rb b/config/routes.rb index d34aa775..ea8d1f4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -175,4 +175,10 @@ Rails.application.routes.draw do post 'subscriptions/callback', to: 'subscriptions#callback' end end + + # Digest routes + namespace :digests do + get 'preview/:period/:year(/:month)', to: 'digests#preview', as: :preview + post 'send_test/:period', to: 'digests#send_test', as: :send_test + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 928df596..388fd83e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -335,5 +335,116 @@ RSpec.describe User, type: :model do expect(user.timezone).to eq(Time.zone.name) end end + + describe 'digest preferences' do + let(:user) { create(:user, settings: {}) } + + describe '#digest_enabled?' do + context 'when digest preferences not set' do + it 'returns false' do + expect(user.digest_enabled?).to be false + end + end + + context 'when monthly digest is enabled' do + before do + user.update!(settings: { + 'digest_preferences' => { + 'monthly' => { 'enabled' => true } + } + }) + end + + it 'returns true' do + expect(user.digest_enabled?(:monthly)).to be true + end + end + + context 'when monthly digest is disabled' do + before do + user.update!(settings: { + 'digest_preferences' => { + 'monthly' => { 'enabled' => false } + } + }) + end + + it 'returns false' do + expect(user.digest_enabled?(:monthly)).to be false + end + end + end + + describe '#enable_digest!' do + it 'enables the digest for given period' do + expect { user.enable_digest!(:monthly) } + .to change { user.reload.digest_enabled?(:monthly) } + .from(false).to(true) + end + + it 'preserves other settings' do + user.update!(settings: { 'other_setting' => 'value' }) + user.enable_digest!(:monthly) + + expect(user.settings['other_setting']).to eq('value') + end + end + + describe '#disable_digest!' do + before do + user.enable_digest!(:monthly) + end + + it 'disables the digest for given period' do + expect { user.disable_digest!(:monthly) } + .to change { user.reload.digest_enabled?(:monthly) } + .from(true).to(false) + end + end + + describe '#digest_last_sent_at' do + context 'when never sent' do + it 'returns nil' do + expect(user.digest_last_sent_at(:monthly)).to be_nil + end + end + + context 'when previously sent' do + let(:sent_time) { Time.zone.parse('2024-01-01 09:00:00') } + + before do + user.update!(settings: { + 'digest_preferences' => { + 'monthly' => { + 'enabled' => true, + 'last_sent_at' => sent_time.iso8601 + } + } + }) + end + + it 'returns the last sent time' do + expect(user.digest_last_sent_at(:monthly)).to be_within(1.second).of(sent_time) + end + end + + context 'when timestamp is invalid' do + before do + user.update!(settings: { + 'digest_preferences' => { + 'monthly' => { + 'enabled' => true, + 'last_sent_at' => 'invalid' + } + } + }) + end + + it 'returns nil' do + expect(user.digest_last_sent_at(:monthly)).to be_nil + end + end + end + end end end diff --git a/spec/services/digests/calculator_spec.rb b/spec/services/digests/calculator_spec.rb new file mode 100644 index 00000000..0cc8bcf1 --- /dev/null +++ b/spec/services/digests/calculator_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Digests::Calculator do + let(:user) { create(:user) } + let(:year) { 2024 } + let(:month) { 12 } + + describe '#call' do + context 'for monthly digest' do + subject(:calculator) { described_class.new(user, period: :monthly, year: year, month: month) } + + it 'returns a hash with all required keys' do + result = calculator.call + + expect(result).to be_a(Hash) + expect(result.keys).to match_array(%i[ + period_type year month period_label overview distance_stats + top_cities visited_places trips all_time_stats + ]) + end + + it 'sets period_type to monthly' do + result = calculator.call + expect(result[:period_type]).to eq(:monthly) + end + + it 'sets correct year and month' do + result = calculator.call + expect(result[:year]).to eq(year) + expect(result[:month]).to eq(month) + end + + it 'generates correct period_label' do + result = calculator.call + expect(result[:period_label]).to eq('December 2024') + end + + context 'when error occurs' do + before do + allow_any_instance_of(Digests::Queries::Overview).to receive(:call).and_raise(StandardError, 'Test error') + end + + it 'returns nil and logs error' do + expect(calculator.call).to be_nil + end + end + end + + context 'for yearly digest' do + subject(:calculator) { described_class.new(user, period: :yearly, year: year) } + + it 'generates correct period_label' do + result = calculator.call + expect(result[:period_label]).to eq('2024') + end + + it 'sets period_type to yearly' do + result = calculator.call + expect(result[:period_type]).to eq(:yearly) + end + end + + context 'with actual data' do + let!(:points) do + 3.times.map do |i| + create(:point, + user: user, + timestamp: Time.new(2024, 12, 15, 12, i).to_i, + city: 'Berlin', + country_name: 'Germany') + end + end + + subject(:calculator) { described_class.new(user, period: :monthly, year: year, month: month) } + + it 'includes overview data' do + result = calculator.call + expect(result[:overview]).to be_a(Hash) + expect(result[:overview][:points_count]).to eq(3) + end + + it 'includes distance stats' do + result = calculator.call + expect(result[:distance_stats]).to be_a(Hash) + expect(result[:distance_stats]).to have_key(:total_distance_km) + end + + it 'includes top cities' do + result = calculator.call + expect(result[:top_cities]).to be_an(Array) + end + + it 'includes all time stats' do + result = calculator.call + expect(result[:all_time_stats]).to be_a(Hash) + expect(result[:all_time_stats][:total_countries]).to be >= 0 + end + end + end +end diff --git a/spec/services/digests/queries/all_time_spec.rb b/spec/services/digests/queries/all_time_spec.rb new file mode 100644 index 00000000..c9add378 --- /dev/null +++ b/spec/services/digests/queries/all_time_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Digests::Queries::AllTime do + let(:user) { create(:user) } + subject(:query) { described_class.new(user) } + + describe '#call' do + context 'with no data' do + it 'returns zero counts' do + result = query.call + + expect(result[:total_countries]).to eq(0) + expect(result[:total_cities]).to eq(0) + expect(result[:total_places]).to eq(0) + expect(result[:total_distance_km]).to eq(0) + expect(result[:first_point_date]).to be_nil + end + + it 'calculates account age' do + result = query.call + expect(result[:account_age_days]).to be >= 0 + end + end + + context 'with data' do + let!(:points) do + [ + create(:point, user: user, timestamp: Time.zone.parse('2024-01-15 10:00').to_i, city: 'Berlin', country_name: 'Germany'), + create(:point, user: user, timestamp: Time.zone.parse('2024-06-15 10:00').to_i, city: 'Paris', country_name: 'France') + ] + end + + let!(:stat) { create(:stat, user: user, year: 2024, month: 1, distance: 100) } + + it 'counts total countries' do + result = query.call + expect(result[:total_countries]).to eq(2) + end + + it 'counts total cities' do + result = query.call + expect(result[:total_cities]).to eq(2) + end + + it 'sums distance from stats' do + result = query.call + expect(result[:total_distance_km]).to eq(100) + end + + it 'finds first point date' do + result = query.call + expect(result[:first_point_date]).to eq(Date.new(2024, 1, 15)) + end + end + end +end diff --git a/spec/services/digests/queries/cities_spec.rb b/spec/services/digests/queries/cities_spec.rb new file mode 100644 index 00000000..707d36ca --- /dev/null +++ b/spec/services/digests/queries/cities_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Digests::Queries::Cities do + let(:user) { create(:user) } + let(:date_range) { Time.zone.parse('2024-12-01')..Time.zone.parse('2024-12-31').end_of_day } + subject(:query) { described_class.new(user, date_range) } + + describe '#call' do + context 'with no points' do + it 'returns empty array' do + result = query.call + expect(result).to eq([]) + end + end + + context 'with points in multiple cities' do + let!(:points) do + [ + create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 10:00').to_i, city: 'Berlin'), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 14:00').to_i, city: 'Berlin'), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-16 10:00').to_i, city: 'Hamburg'), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-17 10:00').to_i, city: 'Berlin') + ] + end + + it 'returns cities sorted by visit count' do + result = query.call + + expect(result).to be_an(Array) + expect(result.first[:name]).to eq('Berlin') + expect(result.first[:visits]).to eq(3) + expect(result.second[:name]).to eq('Hamburg') + expect(result.second[:visits]).to eq(1) + end + + it 'respects the limit parameter' do + limited_query = described_class.new(user, date_range, limit: 1) + result = limited_query.call + + expect(result.length).to eq(1) + end + end + end +end diff --git a/spec/services/digests/queries/distance_spec.rb b/spec/services/digests/queries/distance_spec.rb new file mode 100644 index 00000000..0c9dec84 --- /dev/null +++ b/spec/services/digests/queries/distance_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Digests::Queries::Distance do + let(:user) { create(:user) } + let(:date_range) { Time.zone.parse('2024-12-01')..Time.zone.parse('2024-12-31').end_of_day } + subject(:query) { described_class.new(user, date_range) } + + describe '#call' do + context 'with no points' do + it 'returns zero distance' do + result = query.call + + expect(result[:total_distance_km]).to eq(0) + expect(result[:daily_average_km]).to eq(0) + expect(result[:max_distance_day]).to be_nil + end + end + + context 'with points' do + let!(:points) do + [ + create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 10:00').to_i, latitude: 52.52, longitude: 13.405), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 14:00').to_i, latitude: 52.51, longitude: 13.395), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-16 10:00').to_i, latitude: 52.50, longitude: 13.385) + ] + end + + it 'calculates total distance' do + result = query.call + expect(result[:total_distance_km]).to be > 0 + end + + it 'calculates daily average' do + result = query.call + expect(result[:daily_average_km]).to be >= 0 + end + + it 'finds max distance day' do + result = query.call + expect(result[:max_distance_day]).to be_a(Hash) + expect(result[:max_distance_day][:date]).to be_a(Date) + expect(result[:max_distance_day][:distance_km]).to be > 0 + end + end + end +end diff --git a/spec/services/digests/queries/overview_spec.rb b/spec/services/digests/queries/overview_spec.rb new file mode 100644 index 00000000..e050b25d --- /dev/null +++ b/spec/services/digests/queries/overview_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Digests::Queries::Overview do + let(:user) { create(:user) } + let(:date_range) { Time.zone.parse('2024-12-01')..Time.zone.parse('2024-12-31').end_of_day } + subject(:query) { described_class.new(user, date_range) } + + describe '#call' do + context 'with no data' do + it 'returns zero counts' do + result = query.call + + expect(result[:countries_count]).to eq(0) + expect(result[:cities_count]).to eq(0) + expect(result[:places_count]).to eq(0) + expect(result[:points_count]).to eq(0) + end + end + + context 'with points in date range' do + let!(:points_in_range) do + [ + create(:point, user: user, timestamp: Time.zone.parse('2024-12-15 12:00').to_i, city: 'Berlin', country_name: 'Germany'), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-16 12:00').to_i, city: 'Hamburg', country_name: 'Germany'), + create(:point, user: user, timestamp: Time.zone.parse('2024-12-17 12:00').to_i, city: 'Berlin', country_name: 'Germany') + ] + end + + let!(:points_outside_range) do + create(:point, user: user, timestamp: Time.zone.parse('2024-11-15 12:00').to_i, city: 'Paris', country_name: 'France') + end + + it 'counts only points in range' do + result = query.call + expect(result[:points_count]).to eq(3) + end + + it 'counts distinct countries' do + result = query.call + expect(result[:countries_count]).to eq(1) + end + + it 'counts distinct cities' do + result = query.call + expect(result[:cities_count]).to eq(2) + end + end + + context 'with visits and areas' do + let(:area) { create(:area, user: user) } + let!(:visit) do + create(:visit, + user: user, + area: area, + started_at: Time.zone.parse('2024-12-15 12:00'), + ended_at: Time.zone.parse('2024-12-15 14:00')) + end + + it 'counts places' do + result = query.call + expect(result[:places_count]).to eq(1) + end + end + end +end