From 1468f1f9dc8ee9c3569c5ea9c503228d773980b3 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 4 Jul 2025 20:09:06 +0200 Subject: [PATCH] Remove tracks api endpoint --- app/controllers/api/v1/tracks_controller.rb | 39 ------ app/controllers/map_controller.rb | 78 ++++++++---- app/javascript/maps/tracks_README.md | 119 ------------------ app/models/concerns/calculateable.rb | 2 +- app/serializers/track_serializer.rb | 16 +-- app/services/tracks/README.md | 130 -------------------- config/sidekiq.yml | 1 + db/migrate/20250703193656_create_tracks.rb | 2 +- db/schema.rb | 2 +- spec/jobs/tracks/create_job_spec.rb | 21 ++-- spec/models/point_spec.rb | 11 ++ spec/models/track_spec.rb | 2 +- spec/requests/api/v1/tracks_spec.rb | 7 -- spec/serializers/track_serializer_spec.rb | 60 +++++---- 14 files changed, 120 insertions(+), 370 deletions(-) delete mode 100644 app/controllers/api/v1/tracks_controller.rb delete mode 100644 app/javascript/maps/tracks_README.md delete mode 100644 app/services/tracks/README.md delete mode 100644 spec/requests/api/v1/tracks_spec.rb diff --git a/app/controllers/api/v1/tracks_controller.rb b/app/controllers/api/v1/tracks_controller.rb deleted file mode 100644 index 4db99a70..00000000 --- a/app/controllers/api/v1/tracks_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::TracksController < ApiController - def index - start_time = parse_timestamp(params[:start_at]) - end_time = parse_timestamp(params[:end_at]) - - # Find tracks that overlap with the date range - @tracks = current_api_user.tracks - .where('start_at <= ? AND end_at >= ?', end_time, start_time) - .order(:start_at) - - render json: { tracks: @tracks } - end - - def create - tracks_created = Tracks::CreateFromPoints.new(current_api_user).call - - render json: { - message: "#{tracks_created} tracks created successfully", - tracks_created: tracks_created - } - end - - private - - def parse_timestamp(timestamp_param) - return Time.current if timestamp_param.blank? - - # Handle both Unix timestamps and ISO date strings - if timestamp_param.to_s.match?(/^\d+$/) - Time.zone.at(timestamp_param.to_i) - else - Time.zone.parse(timestamp_param) - end - rescue ArgumentError - Time.current - end -end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index d653c65e..648b30d3 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -4,21 +4,65 @@ class MapController < ApplicationController before_action :authenticate_user! def index - @points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) - - @coordinates = - @points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country, :track_id) - .map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] } - @tracks = TrackSerializer.new(current_user, @coordinates).call - @distance = distance - @start_at = Time.zone.at(start_at) - @end_at = Time.zone.at(end_at) - @years = (@start_at.year..@end_at.year).to_a - @points_number = @coordinates.count + @points = filtered_points + @coordinates = build_coordinates + @tracks = build_tracks + @distance = calculate_distance + @start_at = parsed_start_at + @end_at = parsed_end_at + @years = years_range + @points_number = points_count end private + def filtered_points + points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) + end + + def build_coordinates + @points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country, :track_id) + .map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] } + end + + def extract_track_ids + # Extract track IDs from coordinates (index 8: [lat, lng, battery, altitude, timestamp, velocity, id, country, track_id]) + @coordinates.map { |coord| coord[8]&.to_i }.compact.uniq.reject(&:zero?) + end + + def build_tracks + track_ids = extract_track_ids + TrackSerializer.new(current_user, track_ids).call + end + + def calculate_distance + distance = 0 + + @coordinates.each_cons(2) do + distance += Geocoder::Calculations.distance_between( + [_1[0], _1[1]], [_2[0], _2[1]], units: current_user.safe_settings.distance_unit.to_sym + ) + end + + distance.round(1) + end + + def parsed_start_at + Time.zone.at(start_at) + end + + def parsed_end_at + Time.zone.at(end_at) + end + + def years_range + (parsed_start_at.year..parsed_end_at.year).to_a + end + + def points_count + @coordinates.count + end + def start_at return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present? return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any? @@ -33,18 +77,6 @@ class MapController < ApplicationController Time.zone.today.end_of_day.to_i end - def distance - @distance ||= 0 - - @coordinates.each_cons(2) do - @distance += Geocoder::Calculations.distance_between( - [_1[0], _1[1]], [_2[0], _2[1]], units: current_user.safe_settings.distance_unit.to_sym - ) - end - - @distance.round(1) - end - def points params[:import_id] ? points_from_import : points_from_user end diff --git a/app/javascript/maps/tracks_README.md b/app/javascript/maps/tracks_README.md deleted file mode 100644 index a46b6115..00000000 --- a/app/javascript/maps/tracks_README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Tracks Map Layer - -This module provides functionality for rendering tracks as a separate layer on Leaflet maps in Dawarich. - -## Features - -- **Distinct visual styling** - Tracks use brown color to differentiate from blue polylines -- **Interactive hover/click** - Rich popups with track details including distance, duration, elevation -- **Consistent styling** - All tracks use the same brown color for easy identification -- **Layer management** - Integrates with Leaflet layer control -- **Performance optimized** - Uses canvas rendering and efficient event handling - -## Usage - -### Basic Integration - -The tracks layer is automatically integrated into the main maps controller: - -```javascript -// Import the tracks module -import { createTracksLayer, updateTracksColors } from "../maps/tracks"; - -// Create tracks layer -const tracksLayer = createTracksLayer(tracksData, map, userSettings, distanceUnit); - -// Add to map -tracksLayer.addTo(map); -``` - -### Styling - -All tracks use a consistent brown color (#8B4513) to ensure they are easily distinguishable from the blue polylines used for regular routes. - -### Track Data Format - -Tracks expect data in this format: - -```javascript -{ - id: 123, - start_at: "2025-01-15T10:00:00Z", - end_at: "2025-01-15T11:30:00Z", - distance: 15000, // meters - duration: 5400, // seconds - avg_speed: 25.5, // km/h - elevation_gain: 200, // meters - elevation_loss: 150, // meters - elevation_max: 500, // meters - elevation_min: 300, // meters - original_path: "LINESTRING(-74.0060 40.7128, -74.0070 40.7130)", // PostGIS format - // OR - coordinates: [[40.7128, -74.0060], [40.7130, -74.0070]], // [lat, lng] array - // OR - path: [[40.7128, -74.0060], [40.7130, -74.0070]] // alternative coordinate format -} -``` - -### Coordinate Parsing - -The module automatically handles different coordinate formats: - -1. **Array format**: `track.coordinates` or `track.path` as `[[lat, lng], ...]` -2. **PostGIS LineString**: Parses `"LINESTRING(lng lat, lng lat, ...)"` format -3. **Fallback**: Creates simple line from start/end points if available - -### API Integration - -The tracks layer integrates with these API endpoints: - -- **GET `/api/v1/tracks`** - Fetch existing tracks -- **POST `/api/v1/tracks`** - Trigger track generation from points - -### Settings Integration - -Track settings are integrated into the main map settings panel: - -- **Show Tracks** - Toggle track layer visibility -- **Refresh Tracks** - Regenerate tracks from current points - -### Layer Control - -Tracks appear as "Tracks" in the Leaflet layer control, positioned above regular polylines with z-index 460. - -## Visual Features - -### Markers - -- **Start marker**: 🚀 (rocket emoji) -- **End marker**: 🎯 (target emoji) - -### Popup Content - -Track popups display: -- Track ID -- Start/end timestamps -- Duration (formatted as days/hours/minutes) -- Total distance -- Average speed -- Elevation statistics (gain/loss/max/min) - -### Interaction States - -- **Default**: Brown polylines (weight: 4) -- **Hover**: Orange polylines (weight: 6) -- **Clicked**: Red polylines (weight: 8, persistent until clicked elsewhere) - -## Performance Considerations - -- Uses Leaflet canvas renderer for efficient rendering -- Custom pane (`tracksPane`) with z-index 460 -- Efficient coordinate parsing with error handling -- Minimal DOM manipulation during interactions - -## Error Handling - -- Graceful handling of missing coordinate data -- Console warnings for unparseable track data -- Fallback to empty layer if tracks API unavailable -- Error messages for failed track generation diff --git a/app/models/concerns/calculateable.rb b/app/models/concerns/calculateable.rb index cb305a37..ef4c6eee 100644 --- a/app/models/concerns/calculateable.rb +++ b/app/models/concerns/calculateable.rb @@ -68,7 +68,7 @@ module Calculateable def convert_distance_to_meters(calculated_distance) # For Track model - convert to meters for storage (Track expects distance in meters) case user_distance_unit.to_s - when 'miles', 'mi' + when 'mi' (calculated_distance * 1609.344).round(2) # miles to meters else (calculated_distance * 1000).round(2) # km to meters diff --git a/app/serializers/track_serializer.rb b/app/serializers/track_serializer.rb index 56f00f26..7cdd1bbc 100644 --- a/app/serializers/track_serializer.rb +++ b/app/serializers/track_serializer.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true class TrackSerializer - def initialize(user, coordinates) + def initialize(user, track_ids) @user = user - @coordinates = coordinates + @track_ids = track_ids end def call - # Extract track IDs from the coordinates that are already filtered by timeframe - track_ids = extract_track_ids_from_coordinates return [] if track_ids.empty? # Show only tracks that have points in the selected timeframe @@ -29,15 +27,7 @@ class TrackSerializer private - attr_reader :user, :coordinates - - def extract_track_ids_from_coordinates - # Extract track_id from coordinates (index 8: [lat, lng, battery, altitude, timestamp, velocity, id, country, track_id]) - track_ids = coordinates.map { |coord| coord[8]&.to_i }.compact.uniq - track_ids.reject(&:zero?) # Remove any nil/zero track IDs - end - - + attr_reader :user, :track_ids def serialize_track_data( id, start_at, end_at, distance, avg_speed, duration, elevation_gain, diff --git a/app/services/tracks/README.md b/app/services/tracks/README.md deleted file mode 100644 index ac96bf80..00000000 --- a/app/services/tracks/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Tracks Services - -This directory contains services for working with tracks generated from user points. - -## Tracks::CreateFromPoints - -This service takes all points for a user and creates tracks by splitting them based on the user's configured settings for distance and time thresholds. - -### Usage - -```ruby -# Basic usage -user = User.find(123) -service = Tracks::CreateFromPoints.new(user) -tracks_created = service.call - -puts "Created #{tracks_created} tracks for user #{user.email}" -``` - -### How it works - -The service: - -1. **Fetches all user points** ordered by timestamp -2. **Splits points into track segments** based on two thresholds: - - **Distance threshold**: `user.safe_settings.meters_between_routes` (default: 500 meters) - - **Time threshold**: `user.safe_settings.minutes_between_routes` (default: 30 minutes) -3. **Creates Track records** with calculated statistics: - - Distance (in meters) - - Duration (in seconds) - - Average speed (in km/h) - - Elevation statistics (gain, loss, min, max) - - PostGIS LineString path -4. **Associates points with tracks** by updating the `track_id` field - -### Track Splitting Logic - -A new track is created when either condition is met: -- **Time gap**: Time between consecutive points > time threshold -- **Distance gap**: Distance between consecutive points > distance threshold - -### Example with custom settings - -```ruby -# User with custom settings -user.update!(settings: { - 'meters_between_routes' => 1000, # 1km distance threshold - 'minutes_between_routes' => 60 # 1 hour time threshold -}) - -service = Tracks::CreateFromPoints.new(user) -service.call -``` - -### Background Job Usage - -For large datasets, consider running in a background job: - -```ruby -class Tracks::CreateJob < ApplicationJob - queue_as :default - - def perform(user_id) - user = User.find(user_id) - tracks_created = Tracks::CreateFromPoints.new(user).call - - # Create notification for user - Notification.create!( - user: user, - title: 'Tracks Generated', - content: "Created #{tracks_created} tracks from your location data", - kind: :info - ) - end -end - -# Enqueue the job -Tracks::CreateJob.perform_later(user.id) -``` - -### Console Usage - -```ruby -# In Rails console -rails console - -# Generate tracks for a specific user -user = User.find_by(email: 'user@example.com') -Tracks::CreateFromPoints.new(user).call - -# Generate tracks for all users -User.find_each do |user| - tracks_created = Tracks::CreateFromPoints.new(user).call - puts "User #{user.id}: #{tracks_created} tracks created" -end -``` - -### Configuration - -The service respects user settings: - -- `meters_between_routes`: Maximum distance between points in the same track (meters) -- `minutes_between_routes`: Maximum time between points in the same track (minutes) -- `distance_unit`: Used for internal calculations (km/miles) - -### Performance Considerations - -- Uses database transactions for consistency -- Processes points with `find_each` to avoid loading all points into memory -- Destroys existing tracks before regenerating (use with caution) -- For users with many points, consider running as background job - -### Track Statistics - -Each track includes: - -- **start_at/end_at**: First and last point timestamps -- **distance**: Total distance in meters (converted from user's preferred unit) -- **duration**: Total time in seconds -- **avg_speed**: Average speed in km/h -- **elevation_gain/loss**: Cumulative elevation changes -- **elevation_min/max**: Altitude range -- **original_path**: PostGIS LineString geometry - -### Dependencies - -- PostGIS for distance calculations and path geometry -- Existing `Tracks::BuildPath` service for creating LineString geometry -- User settings via `Users::SafeSettings` -- Point model with `Distanceable` concern diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 7bde1468..9ef06b6f 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -6,5 +6,6 @@ - imports - exports - stats + - tracks - reverse_geocoding - visit_suggesting diff --git a/db/migrate/20250703193656_create_tracks.rb b/db/migrate/20250703193656_create_tracks.rb index 94314c71..b89b42dc 100644 --- a/db/migrate/20250703193656_create_tracks.rb +++ b/db/migrate/20250703193656_create_tracks.rb @@ -5,7 +5,7 @@ class CreateTracks < ActiveRecord::Migration[8.0] t.datetime :end_at, null: false t.references :user, null: false, foreign_key: true t.line_string :original_path, null: false - t.float :distance + t.integer :distance t.float :avg_speed t.integer :duration t.integer :elevation_gain diff --git a/db/schema.rb b/db/schema.rb index a89e3d84..837c0927 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -223,7 +223,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do t.datetime "end_at", null: false t.bigint "user_id", null: false t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false - t.float "distance" + t.integer "distance" t.float "avg_speed" t.integer "duration" t.integer "elevation_gain" diff --git a/spec/jobs/tracks/create_job_spec.rb b/spec/jobs/tracks/create_job_spec.rb index 6312523e..0c948a4a 100644 --- a/spec/jobs/tracks/create_job_spec.rb +++ b/spec/jobs/tracks/create_job_spec.rb @@ -6,15 +6,17 @@ RSpec.describe Tracks::CreateJob, type: :job do let(:user) { create(:user) } describe '#perform' do - it 'calls the service and creates a notification' do - service_instance = instance_double(Tracks::CreateFromPoints) + let(:service_instance) { instance_double(Tracks::CreateFromPoints) } + let(:notification_service) { instance_double(Notifications::Create) } + + before do allow(Tracks::CreateFromPoints).to receive(:new).with(user).and_return(service_instance) allow(service_instance).to receive(:call).and_return(3) - - notification_service = instance_double(Notifications::Create) allow(Notifications::Create).to receive(:new).and_return(notification_service) allow(notification_service).to receive(:call) + end + it 'calls the service and creates a notification' do described_class.new.perform(user.id) expect(Tracks::CreateFromPoints).to have_received(:new).with(user) @@ -30,18 +32,17 @@ RSpec.describe Tracks::CreateJob, type: :job do context 'when service raises an error' do let(:error_message) { 'Something went wrong' } + let(:service_instance) { instance_double(Tracks::CreateFromPoints) } + let(:notification_service) { instance_double(Notifications::Create) } before do - service_instance = instance_double(Tracks::CreateFromPoints) allow(Tracks::CreateFromPoints).to receive(:new).with(user).and_return(service_instance) allow(service_instance).to receive(:call).and_raise(StandardError, error_message) + allow(Notifications::Create).to receive(:new).and_return(notification_service) + allow(notification_service).to receive(:call) end it 'creates an error notification' do - notification_service = instance_double(Notifications::Create) - allow(Notifications::Create).to receive(:new).and_return(notification_service) - allow(notification_service).to receive(:call) - described_class.new.perform(user.id) expect(Notifications::Create).to have_received(:new).with( @@ -74,7 +75,7 @@ RSpec.describe Tracks::CreateJob, type: :job do describe 'queue' do it 'is queued on default queue' do - expect(described_class.new.queue_name).to eq('default') + expect(described_class.new.queue_name).to eq('tracks') end end end diff --git a/spec/models/point_spec.rb b/spec/models/point_spec.rb index a7bbb348..076dd218 100644 --- a/spec/models/point_spec.rb +++ b/spec/models/point_spec.rb @@ -29,6 +29,17 @@ RSpec.describe Point, type: :model do expect(point.country_id).to eq(country.id) end end + + describe '#recalculate_track' do + let(:point) { create(:point, track: track) } + let(:track) { create(:track) } + + it 'recalculates the track' do + expect(track).to receive(:recalculate_path_and_distance!) + + point.update(lonlat: 'POINT(-79.85581250721961 15.854775993302411)') + end + end end describe 'scopes' do diff --git a/spec/models/track_spec.rb b/spec/models/track_spec.rb index c351a6ae..04ab9a90 100644 --- a/spec/models/track_spec.rb +++ b/spec/models/track_spec.rb @@ -54,7 +54,7 @@ RSpec.describe Track, type: :model do end end - describe '#recalculate_distance!' do + describe '#recalculate_distance!' do it 'recalculates and saves the distance' do original_distance = track.distance diff --git a/spec/requests/api/v1/tracks_spec.rb b/spec/requests/api/v1/tracks_spec.rb deleted file mode 100644 index f85a6f3d..00000000 --- a/spec/requests/api/v1/tracks_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'rails_helper' - -RSpec.describe "Api::V1::Tracks", type: :request do - describe "GET /index" do - pending "add some examples (or delete) #{__FILE__}" - end -end diff --git a/spec/serializers/track_serializer_spec.rb b/spec/serializers/track_serializer_spec.rb index f08641a6..42f99175 100644 --- a/spec/serializers/track_serializer_spec.rb +++ b/spec/serializers/track_serializer_spec.rb @@ -6,11 +6,13 @@ RSpec.describe TrackSerializer do describe '#call' do let(:user) { create(:user) } - context 'when serializing user tracks without date range restrictions' do - subject(:serializer) { described_class.new(user, 1.year.ago.to_i, 1.year.from_now.to_i).call } + context 'when serializing user tracks with track IDs' do + subject(:serializer) { described_class.new(user, track_ids).call } let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) } let!(:track2) { create(:track, user: user, start_at: 4.hours.ago, end_at: 3.hours.ago) } + let!(:track3) { create(:track, user: user, start_at: 6.hours.ago, end_at: 5.hours.ago) } + let(:track_ids) { [track1.id, track2.id] } it 'returns an array of serialized tracks' do expect(serializer).to be_an(Array) @@ -20,6 +22,7 @@ RSpec.describe TrackSerializer do it 'serializes each track correctly' do serialized_ids = serializer.map { |track| track[:id] } expect(serialized_ids).to contain_exactly(track1.id, track2.id) + expect(serialized_ids).not_to include(track3.id) end it 'formats timestamps as ISO8601 for all tracks' do @@ -49,41 +52,48 @@ RSpec.describe TrackSerializer do expect(track[:elevation_min]).to be_a(Numeric) end end + + it 'orders tracks by start_at in ascending order' do + serialized_tracks = serializer + expect(serialized_tracks.first[:id]).to eq(track2.id) # Started 4 hours ago + expect(serialized_tracks.second[:id]).to eq(track1.id) # Started 2 hours ago + end end - context 'when serializing user tracks with date range' do - subject(:serializer) { described_class.new(user, start_at.to_i, end_at.to_i).call } + context 'when track IDs belong to different users' do + subject(:serializer) { described_class.new(user, track_ids).call } - let(:start_at) { 6.hours.ago } - let(:end_at) { 30.minutes.ago } - let!(:track_in_range) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) } - let!(:track_out_of_range) { create(:track, user: user, start_at: 10.hours.ago, end_at: 9.hours.ago) } + let(:other_user) { create(:user) } + let!(:user_track) { create(:track, user: user) } + let!(:other_user_track) { create(:track, user: other_user) } + let(:track_ids) { [user_track.id, other_user_track.id] } - it 'returns an array of serialized tracks' do - expect(serializer).to be_an(Array) - expect(serializer.length).to eq(1) - end - - it 'only includes tracks within the date range' do + it 'only returns tracks belonging to the specified user' do serialized_ids = serializer.map { |track| track[:id] } - expect(serialized_ids).to contain_exactly(track_in_range.id) - expect(serialized_ids).not_to include(track_out_of_range.id) - end - - it 'formats timestamps as ISO8601' do - serializer.each do |track| - expect(track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) - expect(track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) - end + expect(serialized_ids).to contain_exactly(user_track.id) + expect(serialized_ids).not_to include(other_user_track.id) end end - context 'when user has no tracks' do - subject(:serializer) { described_class.new(user, 1.day.ago.to_i, Time.current.to_i).call } + context 'when track IDs array is empty' do + subject(:serializer) { described_class.new(user, []).call } it 'returns an empty array' do expect(serializer).to eq([]) end end + + context 'when track IDs contain non-existent IDs' do + subject(:serializer) { described_class.new(user, track_ids).call } + + let!(:existing_track) { create(:track, user: user) } + let(:track_ids) { [existing_track.id, 999999] } + + it 'only returns existing tracks' do + serialized_ids = serializer.map { |track| track[:id] } + expect(serialized_ids).to contain_exactly(existing_track.id) + expect(serializer.length).to eq(1) + end + end end end