diff --git a/.app_version b/.app_version index 9b1bb851..8570a3ae 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.37.1 +0.37.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e43696c..45b4e351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [0.37.2] - 2026-01-04 + +## Fixed + +- Months are now correctly ordered (Jan-Dec) in the year-end digest chart instead of being sorted alphabetically. +- Time spent in a country and city is now calculated correctly for the year-end digest email. #2104 +- Updated Trix to fix a XSS vulnerability. #2102 +- Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085 +- In Map v2 settings, you can now enable map to be rendered as a globe. + # [0.37.1] - 2025-12-30 ## Fixed diff --git a/Gemfile b/Gemfile index 8bae70ed..7c988b91 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,7 @@ gem 'aws-sdk-kms', '~> 1.96.0', require: false gem 'aws-sdk-s3', '~> 1.177.0', require: false gem 'bootsnap', require: false gem 'chartkick' +gem 'connection_pool', '< 3' # Pin to 2.x - version 3.0+ has breaking API changes with Rails RedisCacheStore gem 'data_migrate' gem 'devise' gem 'foreman' @@ -48,7 +49,7 @@ gem 'rswag-ui' gem 'rubyzip', '~> 3.2' gem 'sentry-rails', '>= 5.27.0' gem 'sentry-ruby' -gem 'sidekiq', '>= 8.0.5' +gem 'sidekiq', '8.0.10' # Pin to 8.0.x - sidekiq 8.1+ requires connection_pool 3.0+ which has breaking changes with Rails gem 'sidekiq-cron', '>= 2.3.1' gem 'sidekiq-limit_fetch' gem 'sprockets-rails' diff --git a/Gemfile.lock b/Gemfile.lock index e1a1840c..df7a3517 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,7 +109,7 @@ GEM base64 (0.3.0) bcrypt (3.1.20) benchmark (0.5.0) - bigdecimal (3.3.1) + bigdecimal (4.0.1) bindata (2.5.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -129,10 +129,10 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - chartkick (5.2.0) + chartkick (5.2.1) chunky_png (1.4.0) coderay (1.1.3) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.6) connection_pool (2.5.5) crack (1.0.1) bigdecimal @@ -215,7 +215,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) importmap-rails (2.2.2) actionpack (>= 6.0.0) @@ -227,7 +227,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.15.0) + json (2.18.0) json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap @@ -273,11 +273,12 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.2) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.7.3) multi_json (1.15.0) - multi_xml (0.7.1) - bigdecimal (~> 3.1) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) net-http (0.6.0) uri net-imap (0.5.12) @@ -356,7 +357,7 @@ GEM json yaml parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.0) ast (~> 2.4.1) racc patience_diff (1.2.0) @@ -369,7 +370,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.7.0) prometheus_exporter (2.2.0) webrick pry (0.15.2) @@ -462,7 +463,7 @@ GEM tsort redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.26.1) + redis-client (0.26.2) connection_pool regexp_parser (2.11.3) reline (0.6.3) @@ -512,7 +513,7 @@ GEM rswag-ui (2.17.0) actionpack (>= 5.2, < 8.2) railties (>= 5.2, < 8.2) - rubocop (1.81.1) + rubocop (1.82.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -520,20 +521,20 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.48.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.47.1) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-rails (2.33.4) + prism (~> 1.7) + rubocop-rails (2.34.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (1.13.0) - rubyzip (3.2.0) + rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.35.0) base64 (~> 0.2) @@ -541,15 +542,15 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.1.1) + sentry-rails (6.2.0) railties (>= 5.2.0) - sentry-ruby (~> 6.1.1) - sentry-ruby (6.1.1) + sentry-ruby (~> 6.2.0) + sentry-ruby (6.2.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) - sidekiq (8.0.8) + sidekiq (8.0.10) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) @@ -613,7 +614,7 @@ GEM unicode (0.4.4.5) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) @@ -662,6 +663,7 @@ DEPENDENCIES bundler-audit capybara chartkick + connection_pool (< 3) data_migrate database_consistency (>= 2.0.5) debug @@ -711,7 +713,7 @@ DEPENDENCIES sentry-rails (>= 5.27.0) sentry-ruby shoulda-matchers - sidekiq (>= 8.0.5) + sidekiq (= 8.0.10) sidekiq-cron (>= 2.3.1) sidekiq-limit_fetch simplecov diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index f164bbe1..9d2f7805 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController :preferred_map_layer, :points_rendering_mode, :live_map_enabled, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, :speed_colored_routes, :speed_color_scale, :fog_of_war_threshold, - :maps_v2_style, :maps_maplibre_style, + :maps_v2_style, :maps_maplibre_style, :globe_projection, enabled_map_layers: [] ) end diff --git a/app/controllers/users/digests_controller.rb b/app/controllers/users/digests_controller.rb index 5016b81f..f289fbc6 100644 --- a/app/controllers/users/digests_controller.rb +++ b/app/controllers/users/digests_controller.rb @@ -6,7 +6,7 @@ class Users::DigestsController < ApplicationController before_action :authenticate_user! before_action :authenticate_active_user!, only: [:create] - before_action :set_digest, only: [:show] + before_action :set_digest, only: %i[show destroy] def index @digests = current_user.digests.yearly.order(year: :desc) @@ -30,6 +30,12 @@ class Users::DigestsController < ApplicationController end end + def destroy + year = @digest.year + @digest.destroy! + redirect_to users_digests_path, notice: "Year-end digest for #{year} has been deleted", status: :see_other + end + private def set_digest @@ -42,7 +48,7 @@ class Users::DigestsController < ApplicationController tracked_years = current_user.stats.select(:year).distinct.pluck(:year) existing_digests = current_user.digests.yearly.pluck(:year) - (tracked_years - existing_digests).sort.reverse + (tracked_years - existing_digests - [Time.current.year]).sort.reverse end def valid_year?(year) diff --git a/app/helpers/users/digests_helper.rb b/app/helpers/users/digests_helper.rb index 13058203..1dc7d473 100644 --- a/app/helpers/users/digests_helper.rb +++ b/app/helpers/users/digests_helper.rb @@ -2,6 +2,27 @@ module Users module DigestsHelper + PROGRESS_COLORS = %w[ + progress-primary progress-secondary progress-accent + progress-info progress-success progress-warning + ].freeze + + def progress_color_for_index(index) + PROGRESS_COLORS[index % PROGRESS_COLORS.length] + end + + def city_progress_value(city_count, max_cities) + return 0 unless max_cities&.positive? + + (city_count.to_f / max_cities * 100).round + end + + def max_cities_count(toponyms) + return 0 if toponyms.blank? + + toponyms.map { |country| country['cities']&.length || 0 }.max + end + def distance_with_unit(distance_meters, unit) value = Users::Digest.convert_distance(distance_meters, unit).round "#{number_with_delimiter(value)} #{unit}" diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js index 165702e8..f4e266fb 100644 --- a/app/javascript/controllers/maps/maplibre/data_loader.js +++ b/app/javascript/controllers/maps/maplibre/data_loader.js @@ -56,22 +56,36 @@ export class DataLoader { } data.visitsGeoJSON = this.visitsToGeoJSON(data.visits) - // Fetch photos - try { - console.log('[Photos] Fetching photos from:', startDate, 'to', endDate) - data.photos = await this.api.fetchPhotos({ - start_at: startDate, - end_at: endDate - }) - console.log('[Photos] Fetched photos:', data.photos.length, 'photos') - console.log('[Photos] Sample photo:', data.photos[0]) - } catch (error) { - console.error('[Photos] Failed to fetch photos:', error) + // Fetch photos - only if photos layer is enabled and integration is configured + // Skip API call if photos are disabled to avoid blocking on failed integrations + if (this.settings.photosEnabled) { + try { + console.log('[Photos] Fetching photos from:', startDate, 'to', endDate) + // Use Promise.race to enforce a client-side timeout + const photosPromise = this.api.fetchPhotos({ + start_at: startDate, + end_at: endDate + }) + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Photo fetch timeout')), 15000) // 15 second timeout + ) + + data.photos = await Promise.race([photosPromise, timeoutPromise]) + console.log('[Photos] Fetched photos:', data.photos.length, 'photos') + console.log('[Photos] Sample photo:', data.photos[0]) + } catch (error) { + console.warn('[Photos] Failed to fetch photos (non-blocking):', error.message) + data.photos = [] + } + } else { + console.log('[Photos] Photos layer disabled, skipping fetch') data.photos = [] } data.photosGeoJSON = this.photosToGeoJSON(data.photos) console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features') - console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0]) + if (data.photosGeoJSON.features.length > 0) { + console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0]) + } // Fetch areas try { diff --git a/app/javascript/controllers/maps/maplibre/map_initializer.js b/app/javascript/controllers/maps/maplibre/map_initializer.js index b253135e..6952c1ea 100644 --- a/app/javascript/controllers/maps/maplibre/map_initializer.js +++ b/app/javascript/controllers/maps/maplibre/map_initializer.js @@ -16,17 +16,35 @@ export class MapInitializer { mapStyle = 'streets', center = [0, 0], zoom = 2, - showControls = true + showControls = true, + globeProjection = false } = settings const style = await getMapStyle(mapStyle) - const map = new maplibregl.Map({ + const mapOptions = { container, style, center, zoom - }) + } + + const map = new maplibregl.Map(mapOptions) + + // Set globe projection after map loads + if (globeProjection === true || globeProjection === 'true') { + map.on('load', () => { + map.setProjection({ type: 'globe' }) + + // Add atmosphere effect + map.setSky({ + 'atmosphere-blend': [ + 'interpolate', ['linear'], ['zoom'], + 0, 1, 5, 1, 7, 0 + ] + }) + }) + } if (showControls) { map.addControl(new maplibregl.NavigationControl(), 'top-right') diff --git a/app/javascript/controllers/maps/maplibre/settings_manager.js b/app/javascript/controllers/maps/maplibre/settings_manager.js index 02c7ae88..d2e77ebf 100644 --- a/app/javascript/controllers/maps/maplibre/settings_manager.js +++ b/app/javascript/controllers/maps/maplibre/settings_manager.js @@ -91,6 +91,11 @@ export class SettingsController { mapStyleSelect.value = this.settings.mapStyle || 'light' } + // Sync globe projection toggle + if (controller.hasGlobeToggleTarget) { + controller.globeToggleTarget.checked = this.settings.globeProjection || false + } + // Sync fog of war settings const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]') if (fogRadiusInput) { @@ -178,6 +183,22 @@ export class SettingsController { } } + /** + * Toggle globe projection + * Requires page reload to apply since projection is set at map initialization + */ + async toggleGlobe(event) { + const enabled = event.target.checked + await SettingsManager.updateSetting('globeProjection', enabled) + + Toast.info('Globe view will be applied after page reload') + + // Prompt user to reload + if (confirm('Globe view requires a page reload to take effect. Reload now?')) { + window.location.reload() + } + } + /** * Update route opacity in real-time */ diff --git a/app/javascript/controllers/maps/maplibre_controller.js b/app/javascript/controllers/maps/maplibre_controller.js index 5f553d70..57fbe5b4 100644 --- a/app/javascript/controllers/maps/maplibre_controller.js +++ b/app/javascript/controllers/maps/maplibre_controller.js @@ -64,6 +64,8 @@ export default class extends Controller { 'speedColoredToggle', 'speedColorScaleContainer', 'speedColorScaleInput', + // Globe projection + 'globeToggle', // Family members 'familyMembersList', 'familyMembersContainer', @@ -147,7 +149,8 @@ export default class extends Controller { */ async initializeMap() { this.map = await MapInitializer.initialize(this.containerTarget, { - mapStyle: this.settings.mapStyle + mapStyle: this.settings.mapStyle, + globeProjection: this.settings.globeProjection }) } @@ -243,6 +246,7 @@ export default class extends Controller { updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) } updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) } updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) } + toggleGlobe(event) { return this.settingsController.toggleGlobe(event) } // Area Selection Manager methods startSelectArea() { return this.areaSelectionManager.startSelectArea() } diff --git a/app/javascript/maps_maplibre/utils/settings_manager.js b/app/javascript/maps_maplibre/utils/settings_manager.js index a5058e27..aa12d3e8 100644 --- a/app/javascript/maps_maplibre/utils/settings_manager.js +++ b/app/javascript/maps_maplibre/utils/settings_manager.js @@ -14,7 +14,8 @@ const DEFAULT_SETTINGS = { minutesBetweenRoutes: 60, pointsRenderingMode: 'raw', speedColoredRoutes: false, - speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' + speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300', + globeProjection: false } // Mapping between v2 layer names and v1 layer names in enabled_map_layers array @@ -41,7 +42,8 @@ const BACKEND_SETTINGS_MAP = { minutesBetweenRoutes: 'minutes_between_routes', pointsRenderingMode: 'points_rendering_mode', speedColoredRoutes: 'speed_colored_routes', - speedColorScale: 'speed_color_scale' + speedColorScale: 'speed_color_scale', + globeProjection: 'globe_projection' } export class SettingsManager { @@ -152,6 +154,8 @@ export class SettingsManager { value = parseInt(value) || DEFAULT_SETTINGS.minutesBetweenRoutes } else if (frontendKey === 'speedColoredRoutes') { value = value === true || value === 'true' + } else if (frontendKey === 'globeProjection') { + value = value === true || value === 'true' } frontendSettings[frontendKey] = value @@ -219,6 +223,8 @@ export class SettingsManager { value = parseInt(value).toString() } else if (frontendKey === 'speedColoredRoutes') { value = Boolean(value) + } else if (frontendKey === 'globeProjection') { + value = Boolean(value) } backendSettings[backendKey] = value diff --git a/app/jobs/users/digests/calculating_job.rb b/app/jobs/users/digests/calculating_job.rb index aaa6c5fb..05496ac8 100644 --- a/app/jobs/users/digests/calculating_job.rb +++ b/app/jobs/users/digests/calculating_job.rb @@ -4,6 +4,7 @@ class Users::Digests::CalculatingJob < ApplicationJob queue_as :digests def perform(user_id, year) + recalculate_monthly_stats(user_id, year) Users::Digests::CalculateYear.new(user_id, year).call rescue StandardError => e create_digest_failed_notification(user_id, e) @@ -11,6 +12,12 @@ class Users::Digests::CalculatingJob < ApplicationJob private + def recalculate_monthly_stats(user_id, year) + (1..12).each do |month| + Stats::CalculateMonth.new(user_id, year, month).call + end + end + def create_digest_failed_notification(user_id, error) user = User.find(user_id) diff --git a/app/models/user.rb b/app/models/user.rb index 8743c132..4b35390e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,18 +45,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength def countries_visited Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do - points - .without_raw_data - .where.not(country_name: [nil, '']) - .distinct - .pluck(:country_name) - .compact + countries_visited_uncached end end def cities_visited Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do - points.where.not(city: [nil, '']).distinct.pluck(:city).compact + cities_visited_uncached end end @@ -139,17 +134,47 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength Time.zone.name end + # Aggregate countries from all stats' toponyms + # This is more accurate than raw point queries as it uses processed data def countries_visited_uncached - points - .without_raw_data - .where.not(country_name: [nil, '']) - .distinct - .pluck(:country_name) - .compact + countries = Set.new + + stats.find_each do |stat| + toponyms = stat.toponyms + next unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + next unless toponym.is_a?(Hash) + + countries.add(toponym['country']) if toponym['country'].present? + end + end + + countries.to_a.sort end + # Aggregate cities from all stats' toponyms + # This respects MIN_MINUTES_SPENT_IN_CITY since toponyms are already filtered def cities_visited_uncached - points.where.not(city: [nil, '']).distinct.pluck(:city).compact + cities = Set.new + + stats.find_each do |stat| + toponyms = stat.toponyms + next unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + next unless toponym.is_a?(Hash) + next unless toponym['cities'].is_a?(Array) + + toponym['cities'].each do |city| + next unless city.is_a?(Hash) + + cities.add(city['city']) if city['city'].present? + end + end + end + + cities.to_a.sort end def home_place_coordinates diff --git a/app/models/users/digest.rb b/app/models/users/digest.rb index 843aa115..fe4e9321 100644 --- a/app/models/users/digest.rb +++ b/app/models/users/digest.rb @@ -132,6 +132,11 @@ class Users::Digest < ApplicationRecord (all_time_stats['total_distance'] || 0).to_i end + def untracked_days + days_in_year = Date.leap?(year) ? 366 : 365 + [days_in_year - total_tracked_days, 0].max.round(1) + end + def distance_km distance.to_f / 1000 end @@ -151,4 +156,15 @@ class Users::Digest < ApplicationRecord def generate_sharing_uuid self.sharing_uuid ||= SecureRandom.uuid end + + def total_tracked_days + (total_tracked_minutes / 1440.0).round(1) + end + + def total_tracked_minutes + # Use total_country_minutes if available (new digests), + # fall back to summing top_countries_by_time (existing digests) + time_spent_by_location['total_country_minutes'] || + top_countries_by_time.sum { |country| country['minutes'].to_i } + end end diff --git a/app/serializers/api/user_serializer.rb b/app/serializers/api/user_serializer.rb index 9d54ec32..e87e68c8 100644 --- a/app/serializers/api/user_serializer.rb +++ b/app/serializers/api/user_serializer.rb @@ -42,7 +42,8 @@ class Api::UserSerializer photoprism_url: user.safe_settings.photoprism_url, visits_suggestions_enabled: user.safe_settings.visits_suggestions_enabled?, speed_color_scale: user.safe_settings.speed_color_scale, - fog_of_war_threshold: user.safe_settings.fog_of_war_threshold + fog_of_war_threshold: user.safe_settings.fog_of_war_threshold, + globe_projection: user.safe_settings.globe_projection } end diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index 3d3ff2f4..b08d20f3 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -49,6 +49,17 @@ class CountriesAndCities end def calculate_duration_in_minutes(timestamps) - ((timestamps.max - timestamps.min).to_i / 60) + return 0 if timestamps.size < 2 + + sorted = timestamps.sort + total_minutes = 0 + gap_threshold_seconds = ::MIN_MINUTES_SPENT_IN_CITY * 60 + + sorted.each_cons(2) do |prev_ts, curr_ts| + interval_seconds = curr_ts - prev_ts + total_minutes += (interval_seconds / 60) if interval_seconds < gap_threshold_seconds + end + + total_minutes end end diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 0dfcbcd5..7018bbe3 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -31,7 +31,10 @@ class Immich::RequestPhotos while page <= max_pages response = JSON.parse( HTTParty.post( - immich_api_base_url, headers: headers, body: request_body(page) + immich_api_base_url, + headers: headers, + body: request_body(page), + timeout: 10 ).body ) Rails.logger.debug('==== IMMICH RESPONSE ====') @@ -46,6 +49,9 @@ class Immich::RequestPhotos end data.flatten + rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error("Immich photo fetch failed: #{e.message}") + [] end def headers diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 0f7fd93b..44005811 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -43,13 +43,17 @@ class Photoprism::RequestPhotos end data.flatten + rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error("Photoprism photo fetch failed: #{e.message}") + [] end def fetch_page(offset) response = HTTParty.get( photoprism_api_base_url, headers: headers, - query: request_params(offset) + query: request_params(offset), + timeout: 10 ) if response.code != 200 diff --git a/app/services/users/digests/calculate_year.rb b/app/services/users/digests/calculate_year.rb index faea7d50..f97c6578 100644 --- a/app/services/users/digests/calculate_year.rb +++ b/app/services/users/digests/calculate_year.rb @@ -3,6 +3,8 @@ module Users module Digests class CalculateYear + MINUTES_PER_DAY = 1440 + def initialize(user_id, year) @user = ::User.find(user_id) @year = year.to_i @@ -50,7 +52,7 @@ module Users next unless toponym.is_a?(Hash) country = toponym['country'] - next unless country.present? + next if country.blank? if toponym['cities'].is_a?(Array) toponym['cities'].each do |city| @@ -64,7 +66,7 @@ module Users end end - country_cities.sort_by { |country, _| country }.map do |country, cities| + country_cities.sort_by { |_country, cities| -cities.size }.map do |country, cities| { 'country' => country, 'cities' => cities.to_a.sort.map { |city| { 'city' => city } } @@ -88,35 +90,120 @@ module Users end def calculate_time_spent - country_time = Hash.new(0) - city_time = Hash.new(0) + country_minutes = calculate_actual_country_minutes - monthly_stats.each do |stat| - toponyms = stat.toponyms - next unless toponyms.is_a?(Array) + { + 'countries' => format_top_countries(country_minutes), + 'cities' => calculate_city_time_spent, + 'total_country_minutes' => country_minutes.values.sum + } + end - toponyms.each do |toponym| - next unless toponym.is_a?(Hash) + def format_top_countries(country_minutes) + country_minutes + .sort_by { |_, minutes| -minutes } + .first(10) + .map { |name, minutes| { 'name' => name, 'minutes' => minutes } } + end - country = toponym['country'] - next unless toponym['cities'].is_a?(Array) + def calculate_actual_country_minutes + points_by_date = group_points_by_date + country_minutes = Hash.new(0) - toponym['cities'].each do |city| - next unless city.is_a?(Hash) + points_by_date.each do |_date, day_points| + countries_on_day = day_points.map(&:country_name).uniq - stayed_for = city['stayed_for'].to_i - city_name = city['city'] - - country_time[country] += stayed_for if country.present? - city_time[city_name] += stayed_for if city_name.present? - end + if countries_on_day.size == 1 + # Single country day - assign full day + country_minutes[countries_on_day.first] += MINUTES_PER_DAY + else + # Multi-country day - calculate proportional time + calculate_proportional_time(day_points, country_minutes) end end - { - 'countries' => country_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } }, - 'cities' => city_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } } - } + country_minutes + end + + def group_points_by_date + points = fetch_year_points_with_country_ordered + + points.group_by do |point| + Time.zone.at(point.timestamp).to_date + end + end + + def calculate_proportional_time(day_points, country_minutes) + country_spans = Hash.new(0) + points_by_country = day_points.group_by(&:country_name) + + points_by_country.each do |country, country_points| + timestamps = country_points.map(&:timestamp) + span_seconds = timestamps.max - timestamps.min + # Minimum 60 seconds (1 min) for single-point countries + country_spans[country] = [span_seconds, 60].max + end + + total_spans = country_spans.values.sum.to_f + + country_spans.each do |country, span| + proportional_minutes = (span / total_spans * MINUTES_PER_DAY).round + country_minutes[country] += proportional_minutes + end + end + + def fetch_year_points_with_country_ordered + start_of_year = Time.zone.local(year, 1, 1, 0, 0, 0) + end_of_year = start_of_year.end_of_year + + user.points + .without_raw_data + .where('timestamp >= ? AND timestamp <= ?', start_of_year.to_i, end_of_year.to_i) + .where.not(country_name: [nil, '']) + .select(:country_name, :timestamp) + .order(timestamp: :asc) + end + + def calculate_city_time_spent + city_time = aggregate_city_time_from_monthly_stats + + city_time + .sort_by { |_, minutes| -minutes } + .first(10) + .map { |name, minutes| { 'name' => name, 'minutes' => minutes } } + end + + def aggregate_city_time_from_monthly_stats + city_time = Hash.new(0) + + monthly_stats.each do |stat| + process_stat_toponyms(stat, city_time) + end + + city_time + end + + def process_stat_toponyms(stat, city_time) + toponyms = stat.toponyms + return unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + process_toponym_cities(toponym, city_time) + end + end + + def process_toponym_cities(toponym, city_time) + return unless toponym.is_a?(Hash) + return unless toponym['cities'].is_a?(Array) + + toponym['cities'].each do |city| + next unless city.is_a?(Hash) + + stayed_for = city['stayed_for'].to_i + city_name = city['city'] + + city_time[city_name] += stayed_for if city_name.present? + end end def calculate_first_time_visits @@ -129,8 +216,8 @@ module Users def calculate_all_time_stats { - 'total_countries' => user.countries_visited.count, - 'total_cities' => user.cities_visited.count, + 'total_countries' => user.countries_visited_uncached.size, + 'total_cities' => user.cities_visited_uncached.size, 'total_distance' => user.stats.sum(:distance).to_s } end diff --git a/app/services/users/export_data/points.rb b/app/services/users/export_data/points.rb index cf224afa..62a20b5e 100644 --- a/app/services/users/export_data/points.rb +++ b/app/services/users/export_data/points.rb @@ -35,7 +35,7 @@ class Users::ExportData::Points output_file.write('[') - user.points.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, batch_index| + user.points.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, _batch_index| batch_sql = build_batch_query(batch.map(&:id)) result = ActiveRecord::Base.connection.exec_query(batch_sql, 'Points Export Batch') @@ -188,13 +188,13 @@ class Users::ExportData::Points } end - if row['visit_name'] - point_hash['visit_reference'] = { - 'name' => row['visit_name'], - 'started_at' => row['visit_started_at'], - 'ended_at' => row['visit_ended_at'] - } - end + return unless row['visit_name'] + + point_hash['visit_reference'] = { + 'name' => row['visit_name'], + 'started_at' => row['visit_started_at'], + 'ended_at' => row['visit_ended_at'] + } end def log_progress(processed, total) diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb index a2e91f7b..55d5c62d 100644 --- a/app/services/users/safe_settings.rb +++ b/app/services/users/safe_settings.rb @@ -22,7 +22,8 @@ class Users::SafeSettings 'visits_suggestions_enabled' => 'true', 'enabled_map_layers' => %w[Routes Heatmap], 'maps_maplibre_style' => 'light', - 'digest_emails_enabled' => true + 'digest_emails_enabled' => true, + 'globe_projection' => false }.freeze def initialize(settings = {}) @@ -52,7 +53,8 @@ class Users::SafeSettings speed_color_scale: speed_color_scale, fog_of_war_threshold: fog_of_war_threshold, enabled_map_layers: enabled_map_layers, - maps_maplibre_style: maps_maplibre_style + maps_maplibre_style: maps_maplibre_style, + globe_projection: globe_projection } end # rubocop:enable Metrics/MethodLength @@ -141,6 +143,10 @@ class Users::SafeSettings settings['maps_maplibre_style'] end + def globe_projection + ActiveModel::Type::Boolean.new.cast(settings['globe_projection']) + end + def digest_emails_enabled? value = settings['digest_emails_enabled'] return true if value.nil? diff --git a/app/views/map/maplibre/_settings_panel.html.erb b/app/views/map/maplibre/_settings_panel.html.erb index 356c90a6..143718f5 100644 --- a/app/views/map/maplibre/_settings_panel.html.erb +++ b/app/views/map/maplibre/_settings_panel.html.erb @@ -365,6 +365,19 @@ + +
Render map as a 3D globe (requires page reload)
++ <%= icon 'lightbulb' %> Track more in <%= @digest.year + 1 %> to see a fuller picture of your travels! +
+ <% end %>+ 💡 Track more in <%= @digest.year + 1 %> to see a fuller picture of your travels! +
+ <% end %>