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
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' }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<Point>] 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<Point>] 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<Point>] 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<Point>] 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<Point>] 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

View file

@ -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