dawarich/app/models/track.rb
2025-07-23 20:08:24 +02:00

159 lines
4.8 KiB
Ruby

# frozen_string_literal: true
class Track < ApplicationRecord
include Calculateable
include DistanceConvertible
belongs_to :user
has_many :points, dependent: :nullify
validates :start_at, :end_at, :original_path, presence: true
validates :distance, :avg_speed, :duration, numericality: { greater_than_or_equal_to: 0 }
after_update :recalculate_path_and_distance!, if: -> { points.exists? && (saved_change_to_start_at? || saved_change_to_end_at?) }
after_create :broadcast_track_created
after_update :broadcast_track_updated
after_destroy :broadcast_track_destroyed
def self.last_for_day(user, day)
day_start = day.beginning_of_day
day_end = day.end_of_day
where(user: user)
.where(end_at: day_start..day_end)
.order(end_at: :desc)
.first
end
def self.segment_points_in_sql(user_id, start_timestamp, end_timestamp, time_threshold_minutes, distance_threshold_meters, untracked_only: false)
time_threshold_seconds = time_threshold_minutes * 60
where_clause = if untracked_only
"WHERE user_id = $1 AND timestamp BETWEEN $2 AND $3 AND track_id IS NULL"
else
"WHERE user_id = $1 AND timestamp BETWEEN $2 AND $3"
end
sql = <<~SQL
WITH points_with_gaps AS (
SELECT
id,
timestamp,
lonlat,
LAG(lonlat) OVER (ORDER BY timestamp) as prev_lonlat,
LAG(timestamp) OVER (ORDER BY timestamp) as prev_timestamp,
ST_Distance(
lonlat::geography,
LAG(lonlat) OVER (ORDER BY timestamp)::geography
) as distance_meters,
(timestamp - LAG(timestamp) OVER (ORDER BY timestamp)) as time_diff_seconds
FROM points
#{where_clause}
ORDER BY timestamp
),
segment_breaks AS (
SELECT *,
CASE
WHEN prev_lonlat IS NULL THEN 1
WHEN time_diff_seconds > $4 THEN 1
WHEN distance_meters > $5 THEN 1
ELSE 0
END as is_break
FROM points_with_gaps
),
segments AS (
SELECT *,
SUM(is_break) OVER (ORDER BY timestamp ROWS UNBOUNDED PRECEDING) as segment_id
FROM segment_breaks
)
SELECT
segment_id,
array_agg(id ORDER BY timestamp) as point_ids,
count(*) as point_count,
min(timestamp) as start_timestamp,
max(timestamp) as end_timestamp,
sum(COALESCE(distance_meters, 0)) as total_distance_meters
FROM segments
GROUP BY segment_id
HAVING count(*) >= 2
ORDER BY segment_id
SQL
results = Point.connection.exec_query(
sql,
'segment_points_in_sql',
[user_id, start_timestamp, end_timestamp, time_threshold_seconds, distance_threshold_meters]
)
# Convert results to segment data
segments_data = []
results.each do |row|
segments_data << {
segment_id: row['segment_id'].to_i,
point_ids: parse_postgres_array(row['point_ids']),
point_count: row['point_count'].to_i,
start_timestamp: row['start_timestamp'].to_i,
end_timestamp: row['end_timestamp'].to_i,
total_distance_meters: row['total_distance_meters'].to_f
}
end
segments_data
end
# Get actual Point objects for each segment with pre-calculated distances
def self.get_segments_with_points(user_id, start_timestamp, end_timestamp, time_threshold_minutes, distance_threshold_meters, untracked_only: false)
segments_data = segment_points_in_sql(
user_id,
start_timestamp,
end_timestamp,
time_threshold_minutes,
distance_threshold_meters,
untracked_only: untracked_only
)
point_ids = segments_data.flat_map { |seg| seg[:point_ids] }
points_by_id = Point.where(id: point_ids).index_by(&:id)
segments_data.map do |seg_data|
{
points: seg_data[:point_ids].map { |id| points_by_id[id] }.compact,
pre_calculated_distance: seg_data[:total_distance_meters],
start_timestamp: seg_data[:start_timestamp],
end_timestamp: seg_data[:end_timestamp]
}
end
end
# Parse PostgreSQL array format like "{1,2,3}" into Ruby array
def self.parse_postgres_array(pg_array_string)
return [] if pg_array_string.nil? || pg_array_string.empty?
# Remove curly braces and split by comma
pg_array_string.gsub(/[{}]/, '').split(',').map(&:to_i)
end
private
def broadcast_track_created
broadcast_track_update('created')
end
def broadcast_track_updated
broadcast_track_update('updated')
end
def broadcast_track_destroyed
TracksChannel.broadcast_to(user, {
action: 'destroyed',
track_id: id
})
end
def broadcast_track_update(action)
TracksChannel.broadcast_to(user, {
action: action,
track: TrackSerializer.new(self).call
})
end
end