Remove tracks api endpoint

This commit is contained in:
Eugene Burmakin 2025-07-04 20:09:06 +02:00
parent 565f92c463
commit 1468f1f9dc
14 changed files with 120 additions and 370 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,5 +6,6 @@
- imports
- exports
- stats
- tracks
- reverse_geocoding
- visit_suggesting

View file

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

2
db/schema.rb generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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