Add documentation

This commit is contained in:
Eugene Burmakin 2025-07-07 23:12:02 +02:00
parent f33dcdfe21
commit a66f41d9fb
12 changed files with 202 additions and 65 deletions

View file

@ -5,7 +5,6 @@ class Api::V1::TracksController < ApiController
start_at = params[:start_at]&.to_datetime&.to_i start_at = params[:start_at]&.to_datetime&.to_i
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.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 tracks = current_api_user.tracks
.where('start_at <= ? AND end_at >= ?', Time.zone.at(end_at), Time.zone.at(start_at)) .where('start_at <= ? AND end_at >= ?', Time.zone.at(end_at), Time.zone.at(start_at))
.order(start_at: :asc) .order(start_at: :asc)
@ -17,7 +16,6 @@ class Api::V1::TracksController < ApiController
end end
def create def create
# Trigger track generation for the user
Tracks::CreateJob.perform_later(current_api_user.id) Tracks::CreateJob.perform_later(current_api_user.id)
render json: { message: 'Track generation started' } render json: { message: 'Track generation started' }

View file

@ -53,7 +53,6 @@ module Calculateable
end end
def convert_distance_for_storage(calculated_distance) def convert_distance_for_storage(calculated_distance)
# Store distance in user's preferred unit with 2 decimal places precision
calculated_distance.round(2) calculated_distance.round(2)
end end

View file

@ -92,7 +92,6 @@ class Point < ApplicationRecord
end end
def country_name def country_name
# Safely get country name from association or attribute
self.country&.name || read_attribute(:country) || '' self.country&.name || read_attribute(:country) || ''
end end
@ -103,11 +102,9 @@ class Point < ApplicationRecord
end end
def trigger_incremental_track_generation 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 point_date = Time.zone.at(timestamp).to_date
return unless point_date >= 1.day.ago.to_date return unless point_date >= 1.day.ago.to_date
# Schedule incremental track generation for this user and day Tracks::IncrementalGeneratorJob.perform_later(user_id, point_date.to_s, 5)
IncrementalTrackGeneratorJob.perform_later(user_id, point_date.to_s, 5)
end end
end end

View file

@ -14,10 +14,6 @@ class Track < ApplicationRecord
after_update :broadcast_track_updated after_update :broadcast_track_updated
after_destroy :broadcast_track_destroyed 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) def self.last_for_day(user, day)
day_start = day.beginning_of_day day_start = day.beginning_of_day
day_end = day.end_of_day day_end = day.end_of_day

View file

@ -9,43 +9,30 @@ class TrackSerializer
def call def call
return [] if track_ids.empty? return [] if track_ids.empty?
# Show only tracks that have points in the selected timeframe tracks = user.tracks
tracks_data = user.tracks
.where(id: track_ids) .where(id: track_ids)
.order(start_at: :asc) .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, tracks.map { |track| serialize_track_data(track) }
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
end end
private private
attr_reader :user, :track_ids attr_reader :user, :track_ids
def serialize_track_data( def serialize_track_data(track)
id, start_at, end_at, distance, avg_speed, duration, elevation_gain,
elevation_loss, elevation_max, elevation_min, original_path
)
{ {
id: id, id: track.id,
start_at: start_at.iso8601, start_at: track.start_at.iso8601,
end_at: end_at.iso8601, end_at: track.end_at.iso8601,
distance: distance.to_i, distance: track.distance.to_i,
avg_speed: avg_speed.to_f, avg_speed: track.avg_speed.to_f,
duration: duration, duration: track.duration,
elevation_gain: elevation_gain, elevation_gain: track.elevation_gain,
elevation_loss: elevation_loss, elevation_loss: track.elevation_loss,
elevation_max: elevation_max, elevation_max: track.elevation_max,
elevation_min: elevation_min, elevation_min: track.elevation_min,
original_path: original_path.to_s original_path: track.original_path.to_s
} }
end end
end end

View file

@ -4,7 +4,7 @@ class OwnTracks::Params
attr_reader :params attr_reader :params
def initialize(params) def initialize(params)
@params = params.deep_symbolize_keys @params = params.to_h.deep_symbolize_keys
end end
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength

View file

@ -1,5 +1,35 @@
# frozen_string_literal: true # 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 module Tracks
class Generator class Generator
include Tracks::Segmentation include Tracks::Segmentation

View file

@ -1,5 +1,28 @@
# frozen_string_literal: true # 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 Tracks
module IncompleteSegmentHandlers module IncompleteSegmentHandlers
class IgnoreHandler class IgnoreHandler

View file

@ -1,5 +1,28 @@
# frozen_string_literal: true # 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 Tracks
module PointLoaders module PointLoaders
class BulkLoader class BulkLoader

View file

@ -1,5 +1,42 @@
# frozen_string_literal: true # 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 module Tracks::Segmentation
extend ActiveSupport::Concern extend ActiveSupport::Concern

View file

@ -1,11 +1,54 @@
# frozen_string_literal: true # 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 module Tracks::TrackBuilder
extend ActiveSupport::Concern extend ActiveSupport::Concern
# Create a track from an array of points
# @param points [Array<Point>] array of Point objects
# @return [Track, nil] created track or nil if creation failed
def create_track_from_points(points) def create_track_from_points(points)
return nil if points.size < 2 return nil if points.size < 2
@ -37,38 +80,25 @@ module Tracks::TrackBuilder
end end
end end
# Build path from points using existing BuildPath service
# @param points [Array<Point>] array of Point objects
# @return [String] LineString representation of the path
def build_path(points) def build_path(points)
Tracks::BuildPath.new(points.map(&:lonlat)).call Tracks::BuildPath.new(points.map(&:lonlat)).call
end end
# Calculate track distance in user's preferred unit for storage
# @param points [Array<Point>] array of Point objects
# @return [Float] distance in user's preferred unit with 2 decimal places precision
def calculate_track_distance(points) def calculate_track_distance(points)
distance_in_user_unit = Point.total_distance(points, user.safe_settings.distance_unit || 'km') distance_in_user_unit = Point.total_distance(points, user.safe_settings.distance_unit || 'km')
distance_in_user_unit.round(2) distance_in_user_unit.round(2)
end end
# Calculate track duration in seconds
# @param points [Array<Point>] array of Point objects
# @return [Integer] duration in seconds
def calculate_duration(points) def calculate_duration(points)
points.last.timestamp - points.first.timestamp points.last.timestamp - points.first.timestamp
end 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) def calculate_average_speed(distance_in_user_unit, duration_seconds)
return 0.0 if duration_seconds <= 0 || distance_in_user_unit <= 0 return 0.0 if duration_seconds <= 0 || distance_in_user_unit <= 0
# Convert distance to meters for speed calculation # Convert distance to meters for speed calculation
distance_meters = case user.safe_settings.distance_unit distance_meters = case user.safe_settings.distance_unit
when 'miles', 'mi' when 'mi'
distance_in_user_unit * 1609.344 # miles to meters distance_in_user_unit * 1609.344 # miles to meters
else else
distance_in_user_unit * 1000 # km to meters 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 (speed_mps * 3.6).round(2) # m/s to km/h
end end
# Calculate elevation statistics from points
# @param points [Array<Point>] array of Point objects
# @return [Hash] elevation statistics hash
def calculate_elevation_stats(points) def calculate_elevation_stats(points)
altitudes = points.map(&:altitude).compact altitudes = points.map(&:altitude).compact
@ -109,8 +136,6 @@ module Tracks::TrackBuilder
} }
end end
# Default elevation statistics when no altitude data is available
# @return [Hash] default elevation statistics
def default_elevation_stats def default_elevation_stats
{ {
gain: 0, gain: 0,
@ -122,8 +147,6 @@ module Tracks::TrackBuilder
private private
# This method must be implemented by the including class
# @return [User] the user for which tracks are being created
def user def user
raise NotImplementedError, "Including class must implement user method" raise NotImplementedError, "Including class must implement user method"
end end

View file

@ -1,5 +1,31 @@
# frozen_string_literal: true # 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 Tracks
module TrackCleaners module TrackCleaners
class ReplaceCleaner class ReplaceCleaner
@ -17,10 +43,8 @@ module Tracks
if tracks_to_remove.any? if tracks_to_remove.any?
Rails.logger.info "Removing #{tracks_to_remove.count} existing tracks for user #{user.id}" 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) Point.where(track_id: tracks_to_remove.ids).update_all(track_id: nil)
# Remove the tracks
tracks_to_remove.destroy_all tracks_to_remove.destroy_all
end end
end end