From a66f41d9fbcbaf339d572b48a32ceb91b76af9cd Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 7 Jul 2025 23:12:02 +0200 Subject: [PATCH] Add documentation --- app/controllers/api/v1/tracks_controller.rb | 2 - app/models/concerns/calculateable.rb | 1 - app/models/point.rb | 5 +- app/models/track.rb | 4 -- app/serializers/track_serializer.rb | 41 ++++------- app/services/own_tracks/params.rb | 2 +- app/services/tracks/generator.rb | 30 ++++++++ .../ignore_handler.rb | 23 ++++++ .../tracks/point_loaders/bulk_loader.rb | 23 ++++++ app/services/tracks/segmentation.rb | 37 ++++++++++ app/services/tracks/track_builder.rb | 71 ++++++++++++------- .../tracks/track_cleaners/replace_cleaner.rb | 28 +++++++- 12 files changed, 202 insertions(+), 65 deletions(-) diff --git a/app/controllers/api/v1/tracks_controller.rb b/app/controllers/api/v1/tracks_controller.rb index 3f9d02aa..d9cd497f 100644 --- a/app/controllers/api/v1/tracks_controller.rb +++ b/app/controllers/api/v1/tracks_controller.rb @@ -5,7 +5,6 @@ class Api::V1::TracksController < ApiController start_at = params[:start_at]&.to_datetime&.to_i end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i - # Find tracks that overlap with the time range tracks = current_api_user.tracks .where('start_at <= ? AND end_at >= ?', Time.zone.at(end_at), Time.zone.at(start_at)) .order(start_at: :asc) @@ -17,7 +16,6 @@ class Api::V1::TracksController < ApiController end def create - # Trigger track generation for the user Tracks::CreateJob.perform_later(current_api_user.id) render json: { message: 'Track generation started' } diff --git a/app/models/concerns/calculateable.rb b/app/models/concerns/calculateable.rb index 45450e82..2e890d1e 100644 --- a/app/models/concerns/calculateable.rb +++ b/app/models/concerns/calculateable.rb @@ -53,7 +53,6 @@ module Calculateable end def convert_distance_for_storage(calculated_distance) - # Store distance in user's preferred unit with 2 decimal places precision calculated_distance.round(2) end diff --git a/app/models/point.rb b/app/models/point.rb index d04754de..0ca0ac11 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -92,7 +92,6 @@ class Point < ApplicationRecord end def country_name - # Safely get country name from association or attribute self.country&.name || read_attribute(:country) || '' end @@ -103,11 +102,9 @@ class Point < ApplicationRecord end def trigger_incremental_track_generation - # Only trigger for recent points (within last day) to avoid processing old data point_date = Time.zone.at(timestamp).to_date return unless point_date >= 1.day.ago.to_date - # Schedule incremental track generation for this user and day - IncrementalTrackGeneratorJob.perform_later(user_id, point_date.to_s, 5) + Tracks::IncrementalGeneratorJob.perform_later(user_id, point_date.to_s, 5) end end diff --git a/app/models/track.rb b/app/models/track.rb index 79df7251..9bed3e52 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -14,10 +14,6 @@ class Track < ApplicationRecord after_update :broadcast_track_updated after_destroy :broadcast_track_destroyed - # Find the last track for a user on a specific day - # @param user [User] the user to find tracks for - # @param day [Date, Time] the day to search for tracks - # @return [Track, nil] the last track for that day or nil if none found def self.last_for_day(user, day) day_start = day.beginning_of_day day_end = day.end_of_day diff --git a/app/serializers/track_serializer.rb b/app/serializers/track_serializer.rb index 4767735f..1a67ccba 100644 --- a/app/serializers/track_serializer.rb +++ b/app/serializers/track_serializer.rb @@ -9,43 +9,30 @@ class TrackSerializer def call return [] if track_ids.empty? - # Show only tracks that have points in the selected timeframe - tracks_data = user.tracks + tracks = user.tracks .where(id: track_ids) .order(start_at: :asc) - .pluck(:id, :start_at, :end_at, :distance, :avg_speed, :duration, - :elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path) - tracks_data.map do |id, start_at, end_at, distance, avg_speed, duration, - elevation_gain, elevation_loss, elevation_max, elevation_min, original_path| - serialize_track_data( - id, start_at, end_at, distance, avg_speed, duration, elevation_gain, - elevation_loss, elevation_max, elevation_min, original_path - ) - end + tracks.map { |track| serialize_track_data(track) } end private attr_reader :user, :track_ids - def serialize_track_data( - id, start_at, end_at, distance, avg_speed, duration, elevation_gain, - elevation_loss, elevation_max, elevation_min, original_path - ) - + def serialize_track_data(track) { - id: id, - start_at: start_at.iso8601, - end_at: end_at.iso8601, - distance: distance.to_i, - avg_speed: avg_speed.to_f, - duration: duration, - elevation_gain: elevation_gain, - elevation_loss: elevation_loss, - elevation_max: elevation_max, - elevation_min: elevation_min, - original_path: original_path.to_s + id: track.id, + start_at: track.start_at.iso8601, + end_at: track.end_at.iso8601, + distance: track.distance.to_i, + avg_speed: track.avg_speed.to_f, + duration: track.duration, + elevation_gain: track.elevation_gain, + elevation_loss: track.elevation_loss, + elevation_max: track.elevation_max, + elevation_min: track.elevation_min, + original_path: track.original_path.to_s } end end diff --git a/app/services/own_tracks/params.rb b/app/services/own_tracks/params.rb index 34499be5..88533690 100644 --- a/app/services/own_tracks/params.rb +++ b/app/services/own_tracks/params.rb @@ -4,7 +4,7 @@ class OwnTracks::Params attr_reader :params def initialize(params) - @params = params.deep_symbolize_keys + @params = params.to_h.deep_symbolize_keys end # rubocop:disable Metrics/MethodLength diff --git a/app/services/tracks/generator.rb b/app/services/tracks/generator.rb index 712d8dd1..4da74114 100644 --- a/app/services/tracks/generator.rb +++ b/app/services/tracks/generator.rb @@ -1,5 +1,35 @@ # frozen_string_literal: true +# The core track generation engine that orchestrates the entire process of creating tracks from GPS points. +# +# This class uses a flexible strategy pattern to handle different track generation scenarios: +# - Bulk processing: Generate all tracks at once from existing points +# - Incremental processing: Generate tracks as new points arrive +# +# How it works: +# 1. Uses a PointLoader strategy to load points from the database +# 2. Applies segmentation logic to split points into track segments based on time/distance gaps +# 3. Determines which segments should be finalized into tracks vs buffered for later +# 4. Creates Track records from finalized segments with calculated statistics +# 5. Manages cleanup of existing tracks based on the chosen strategy +# +# Strategy Components: +# - point_loader: Loads points from database (BulkLoader, IncrementalLoader) +# - incomplete_segment_handler: Handles segments that aren't ready to finalize (IgnoreHandler, BufferHandler) +# - track_cleaner: Manages existing tracks when regenerating (ReplaceCleaner, NoOpCleaner) +# +# The class includes Tracks::Segmentation for splitting logic and Tracks::TrackBuilder for track creation. +# Distance and time thresholds are configurable per user via their settings. +# +# Example usage: +# generator = Tracks::Generator.new( +# user, +# point_loader: Tracks::PointLoaders::BulkLoader.new(user), +# incomplete_segment_handler: Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user), +# track_cleaner: Tracks::TrackCleaners::ReplaceCleaner.new(user) +# ) +# tracks_created = generator.call +# module Tracks class Generator include Tracks::Segmentation diff --git a/app/services/tracks/incomplete_segment_handlers/ignore_handler.rb b/app/services/tracks/incomplete_segment_handlers/ignore_handler.rb index 0fbd468e..0bdb912a 100644 --- a/app/services/tracks/incomplete_segment_handlers/ignore_handler.rb +++ b/app/services/tracks/incomplete_segment_handlers/ignore_handler.rb @@ -1,5 +1,28 @@ # frozen_string_literal: true +# Incomplete segment handling strategy for bulk track generation. +# +# This handler always finalizes segments immediately without buffering, +# making it suitable for bulk processing where all data is historical +# and no segments are expected to grow with new incoming points. +# +# How it works: +# 1. Always returns true for should_finalize_segment? - every segment becomes a track +# 2. Ignores any incomplete segments (logs them but takes no action) +# 3. Requires no cleanup since no data is buffered +# +# Used primarily for: +# - Bulk track generation from historical data +# - One-time processing where all points are already available +# - Scenarios where you want to create tracks from every valid segment +# +# This strategy is efficient for bulk operations but not suitable for +# real-time processing where segments may grow as new points arrive. +# +# Example usage: +# handler = Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user) +# should_create_track = handler.should_finalize_segment?(segment_points) +# module Tracks module IncompleteSegmentHandlers class IgnoreHandler diff --git a/app/services/tracks/point_loaders/bulk_loader.rb b/app/services/tracks/point_loaders/bulk_loader.rb index 712cb9eb..85fc18e4 100644 --- a/app/services/tracks/point_loaders/bulk_loader.rb +++ b/app/services/tracks/point_loaders/bulk_loader.rb @@ -1,5 +1,28 @@ # frozen_string_literal: true +# Point loading strategy for bulk track generation from existing GPS points. +# +# This loader retrieves all valid points for a user within an optional time range, +# suitable for regenerating all tracks at once or processing historical data. +# +# How it works: +# 1. Queries all points belonging to the user +# 2. Filters out points without valid coordinates or timestamps +# 3. Optionally filters by start_at/end_at time range if provided +# 4. Returns points ordered by timestamp for sequential processing +# +# Used primarily for: +# - Initial track generation when a user first enables tracks +# - Bulk regeneration of all tracks after settings changes +# - Processing historical data imports +# +# The loader is designed to be efficient for large datasets while ensuring +# data integrity by filtering out invalid points upfront. +# +# Example usage: +# loader = Tracks::PointLoaders::BulkLoader.new(user, start_at: 1.week.ago, end_at: Time.current) +# points = loader.load_points +# module Tracks module PointLoaders class BulkLoader diff --git a/app/services/tracks/segmentation.rb b/app/services/tracks/segmentation.rb index e5c61387..7043e8c3 100644 --- a/app/services/tracks/segmentation.rb +++ b/app/services/tracks/segmentation.rb @@ -1,5 +1,42 @@ # frozen_string_literal: true +# Track segmentation logic for splitting GPS points into meaningful track segments. +# +# This module provides the core algorithm for determining where one track ends +# and another begins, based on time gaps and distance jumps between consecutive points. +# +# How it works: +# 1. Analyzes consecutive GPS points to detect gaps that indicate separate journeys +# 2. Uses configurable time and distance thresholds to identify segment boundaries +# 3. Splits large arrays of points into smaller arrays representing individual tracks +# 4. Provides utilities for handling both Point objects and hash representations +# +# Segmentation criteria: +# - Time threshold: Gap longer than X minutes indicates a new track +# - Distance threshold: Jump larger than X meters indicates a new track +# - Minimum segment size: Segments must have at least 2 points to form a track +# +# The module is designed to be included in classes that need segmentation logic +# and requires the including class to implement distance_threshold_meters and +# time_threshold_minutes methods. +# +# Used by: +# - Tracks::Generator for splitting points during track generation +# - Tracks::CreateFromPoints for legacy compatibility +# +# Example usage: +# class MyTrackProcessor +# include Tracks::Segmentation +# +# def distance_threshold_meters; 500; end +# def time_threshold_minutes; 60; end +# +# def process_points(points) +# segments = split_points_into_segments(points) +# # Process each segment... +# end +# end +# module Tracks::Segmentation extend ActiveSupport::Concern diff --git a/app/services/tracks/track_builder.rb b/app/services/tracks/track_builder.rb index f62f7603..343377b1 100644 --- a/app/services/tracks/track_builder.rb +++ b/app/services/tracks/track_builder.rb @@ -1,11 +1,54 @@ # frozen_string_literal: true +# Track creation and statistics calculation module for building Track records from GPS points. +# +# This module provides the core functionality for converting arrays of GPS points into +# Track database records with calculated statistics including distance, duration, speed, +# and elevation metrics. +# +# How it works: +# 1. Takes an array of Point objects representing a track segment +# 2. Creates a Track record with basic temporal and spatial boundaries +# 3. Calculates comprehensive statistics: distance, duration, average speed +# 4. Computes elevation metrics: gain, loss, maximum, minimum +# 5. Builds a LineString path representation for mapping +# 6. Associates all points with the created track +# +# Statistics calculated: +# - Distance: In user's preferred unit (km/miles) with 2 decimal precision +# - Duration: Total time in seconds between first and last point +# - Average speed: In km/h regardless of user's distance unit preference +# - Elevation gain/loss: Cumulative ascent and descent in meters +# - Elevation max/min: Highest and lowest altitudes in the track +# +# The module respects user preferences for distance units and handles missing +# elevation data gracefully by providing default values. +# +# Used by: +# - Tracks::Generator for creating tracks during generation +# - Any class that needs to convert point arrays to Track records +# +# Example usage: +# class MyTrackProcessor +# include Tracks::TrackBuilder +# +# def initialize(user) +# @user = user +# end +# +# def process_segment(points) +# track = create_track_from_points(points) +# # Track now exists with calculated statistics +# end +# +# private +# +# attr_reader :user +# end +# module Tracks::TrackBuilder extend ActiveSupport::Concern - # Create a track from an array of points - # @param points [Array] array of Point objects - # @return [Track, nil] created track or nil if creation failed def create_track_from_points(points) return nil if points.size < 2 @@ -37,38 +80,25 @@ module Tracks::TrackBuilder end end - # Build path from points using existing BuildPath service - # @param points [Array] array of Point objects - # @return [String] LineString representation of the path def build_path(points) Tracks::BuildPath.new(points.map(&:lonlat)).call end - # Calculate track distance in user's preferred unit for storage - # @param points [Array] array of Point objects - # @return [Float] distance in user's preferred unit with 2 decimal places precision def calculate_track_distance(points) distance_in_user_unit = Point.total_distance(points, user.safe_settings.distance_unit || 'km') distance_in_user_unit.round(2) end - # Calculate track duration in seconds - # @param points [Array] array of Point objects - # @return [Integer] duration in seconds def calculate_duration(points) points.last.timestamp - points.first.timestamp end - # Calculate average speed in km/h - # @param distance_in_user_unit [Numeric] distance in user's preferred unit - # @param duration_seconds [Numeric] duration in seconds - # @return [Float] average speed in km/h def calculate_average_speed(distance_in_user_unit, duration_seconds) return 0.0 if duration_seconds <= 0 || distance_in_user_unit <= 0 # Convert distance to meters for speed calculation distance_meters = case user.safe_settings.distance_unit - when 'miles', 'mi' + when 'mi' distance_in_user_unit * 1609.344 # miles to meters else distance_in_user_unit * 1000 # km to meters @@ -79,9 +109,6 @@ module Tracks::TrackBuilder (speed_mps * 3.6).round(2) # m/s to km/h end - # Calculate elevation statistics from points - # @param points [Array] array of Point objects - # @return [Hash] elevation statistics hash def calculate_elevation_stats(points) altitudes = points.map(&:altitude).compact @@ -109,8 +136,6 @@ module Tracks::TrackBuilder } end - # Default elevation statistics when no altitude data is available - # @return [Hash] default elevation statistics def default_elevation_stats { gain: 0, @@ -122,8 +147,6 @@ module Tracks::TrackBuilder private - # This method must be implemented by the including class - # @return [User] the user for which tracks are being created def user raise NotImplementedError, "Including class must implement user method" end diff --git a/app/services/tracks/track_cleaners/replace_cleaner.rb b/app/services/tracks/track_cleaners/replace_cleaner.rb index 6b65f585..ff295179 100644 --- a/app/services/tracks/track_cleaners/replace_cleaner.rb +++ b/app/services/tracks/track_cleaners/replace_cleaner.rb @@ -1,5 +1,31 @@ # frozen_string_literal: true +# Track cleaning strategy for bulk track regeneration. +# +# This cleaner removes existing tracks before generating new ones, +# ensuring a clean slate for bulk processing without duplicate tracks. +# +# How it works: +# 1. Finds all existing tracks for the user within the specified time range +# 2. Detaches all points from these tracks (sets track_id to nil) +# 3. Destroys the existing track records +# 4. Allows the generator to create fresh tracks from the same points +# +# Used primarily for: +# - Bulk track regeneration after settings changes +# - Reprocessing historical data with updated algorithms +# - Ensuring consistency when tracks need to be rebuilt +# +# The cleaner respects optional time boundaries (start_at/end_at) to enable +# partial regeneration of tracks within specific time windows. +# +# This strategy is essential for bulk operations but should not be used +# for incremental processing where existing tracks should be preserved. +# +# Example usage: +# cleaner = Tracks::TrackCleaners::ReplaceCleaner.new(user, start_at: 1.week.ago, end_at: Time.current) +# cleaner.cleanup_if_needed +# module Tracks module TrackCleaners class ReplaceCleaner @@ -17,10 +43,8 @@ module Tracks if tracks_to_remove.any? Rails.logger.info "Removing #{tracks_to_remove.count} existing tracks for user #{user.id}" - # Set track_id to nil for all points in these tracks Point.where(track_id: tracks_to_remove.ids).update_all(track_id: nil) - # Remove the tracks tracks_to_remove.destroy_all end end