mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Compare commits
37 commits
5b1c130f0e
...
6db9ce978e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db9ce978e | ||
|
|
b995594b8b | ||
|
|
f8c509912b | ||
|
|
f3d4a1431b | ||
|
|
7f5d84e18f | ||
|
|
60802a6f44 | ||
|
|
460d008152 | ||
|
|
31b23745f8 | ||
|
|
e3d3a92faa | ||
|
|
2e6d1bdef6 | ||
|
|
b2d8f85d35 | ||
|
|
b94be44cbf | ||
|
|
cb9525cb77 | ||
|
|
e127511262 | ||
|
|
6fdecb1724 | ||
|
|
b55b1eb018 | ||
|
|
4e93f60eac | ||
|
|
5090a52f79 | ||
|
|
9c7084a10b | ||
|
|
17340079ce | ||
|
|
abe0129d03 | ||
|
|
9631696231 | ||
|
|
9fb251fa4a | ||
|
|
7920209187 | ||
|
|
94f6dbe18e | ||
|
|
51dd2e0a4b | ||
|
|
bf199de2a0 | ||
|
|
2e46069fcc | ||
|
|
7ea149bd4e | ||
|
|
88e3f53cc5 | ||
|
|
278a4d28b5 | ||
|
|
4239f5b31a | ||
|
|
5a4a5e9625 | ||
|
|
d8033a1e27 | ||
|
|
2ac8fba058 | ||
|
|
abfd3be1c5 | ||
|
|
35f4c0f1f6 |
36 changed files with 417 additions and 100 deletions
|
|
@ -1 +1 @@
|
|||
0.30.2
|
||||
0.30.4
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -65,6 +65,7 @@
|
|||
.dotnet/
|
||||
.cursorrules
|
||||
.cursormemory.md
|
||||
.serena/project.yml
|
||||
|
||||
/config/credentials/production.key
|
||||
/config/credentials/production.yml.enc
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [0.30.4] - 2025-07-26
|
||||
|
||||
## Added
|
||||
|
||||
- Prometheus metrics are now available at `/metrics`. Configure `METRICS_USERNAME` and `METRICS_PASSWORD` environment variables for basic authentication. All other prometheus-related environment variables are also necessary.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- The Warden error in jobs is now fixed. #1556
|
||||
- The Live Map setting is now respected.
|
||||
- The Live Map info modal is now displayed. #665
|
||||
- GPX from Basecamp is now supported. #790
|
||||
- The "Delete Selected" button is now hidden when no points are selected. #1025
|
||||
|
||||
|
||||
# [0.30.3] - 2025-07-23
|
||||
|
||||
## Changed
|
||||
|
||||
- Track generation is now significantly faster and less resource intensive.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Distance on the stats page is now rounded. #1548
|
||||
- Non-selfhosted users can now export and import their account data.
|
||||
|
||||
|
||||
# [0.30.2] - 2025-07-22
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
17
app/controllers/metrics_controller.rb
Normal file
17
app/controllers/metrics_controller.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MetricsController < ApplicationController
|
||||
http_basic_authenticate_with name: METRICS_USERNAME, password: METRICS_PASSWORD, only: :index
|
||||
|
||||
def index
|
||||
result = PrometheusMetrics.fetch_data
|
||||
|
||||
if result[:success]
|
||||
render plain: result[:data], content_type: 'text/plain'
|
||||
elsif result[:error] == 'Prometheus exporter not enabled'
|
||||
head :not_found
|
||||
else
|
||||
head :service_unavailable
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -18,7 +18,14 @@ class PointsController < ApplicationController
|
|||
end
|
||||
|
||||
def bulk_destroy
|
||||
current_user.tracked_points.where(id: params[:point_ids].compact).destroy_all
|
||||
point_ids = params[:point_ids]&.compact&.reject(&:blank?)
|
||||
|
||||
redirect_to points_url(preserved_params),
|
||||
alert: 'No points selected.',
|
||||
status: :see_other and return if point_ids.blank?
|
||||
|
||||
current_user.tracked_points.where(id: point_ids).destroy_all
|
||||
|
||||
redirect_to points_url(preserved_params),
|
||||
notice: 'Points were successfully destroyed.',
|
||||
status: :see_other
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Settings::UsersController < ApplicationController
|
||||
before_action :authenticate_self_hosted!
|
||||
before_action :authenticate_self_hosted!, except: [:export, :import]
|
||||
before_action :authenticate_admin!, except: [:export, :import]
|
||||
before_action :authenticate_user!, only: [:export, :import]
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import BaseController from "./base_controller"
|
|||
|
||||
// Connects to data-controller="checkbox-select-all"
|
||||
export default class extends BaseController {
|
||||
static targets = ["parent", "child"]
|
||||
static targets = ["parent", "child", "deleteButton"]
|
||||
|
||||
connect() {
|
||||
this.parentTarget.checked = false
|
||||
this.childTargets.map(x => x.checked = false)
|
||||
this.updateDeleteButtonVisibility()
|
||||
}
|
||||
|
||||
toggleChildren() {
|
||||
|
|
@ -15,6 +16,7 @@ export default class extends BaseController {
|
|||
} else {
|
||||
this.childTargets.map(x => x.checked = false)
|
||||
}
|
||||
this.updateDeleteButtonVisibility()
|
||||
}
|
||||
|
||||
toggleParent() {
|
||||
|
|
@ -23,5 +25,14 @@ export default class extends BaseController {
|
|||
} else {
|
||||
this.parentTarget.checked = true
|
||||
}
|
||||
this.updateDeleteButtonVisibility()
|
||||
}
|
||||
|
||||
updateDeleteButtonVisibility() {
|
||||
const hasCheckedItems = this.childTargets.some(target => target.checked)
|
||||
|
||||
if (this.hasDeleteButtonTarget) {
|
||||
this.deleteButtonTarget.style.display = hasCheckedItems ? 'inline-block' : 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -969,6 +969,12 @@ export default class extends BaseController {
|
|||
this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
|
||||
this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
|
||||
|
||||
// Update the DOM data attribute to keep it in sync
|
||||
const mapElement = document.getElementById('map');
|
||||
if (mapElement) {
|
||||
mapElement.setAttribute('data-user_settings', JSON.stringify(this.userSettings));
|
||||
}
|
||||
|
||||
// Store current layer states
|
||||
const layerStates = {
|
||||
Points: this.map.hasLayer(this.markersLayer),
|
||||
|
|
|
|||
|
|
@ -37,11 +37,6 @@ module DistanceConvertible
|
|||
distance.to_f / conversion_factor
|
||||
end
|
||||
|
||||
def distance_for_user(user)
|
||||
user_unit = user.safe_settings.distance_unit
|
||||
distance_in_unit(user_unit)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def convert_distance(distance_meters, unit)
|
||||
return 0.0 unless distance_meters.present?
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ module Distanceable
|
|||
|
||||
module ClassMethods
|
||||
def total_distance(points = nil, unit = :km)
|
||||
# Handle method being called directly on relation vs with array
|
||||
if points.nil?
|
||||
calculate_distance_for_relation(unit)
|
||||
else
|
||||
|
|
@ -50,16 +49,48 @@ module Distanceable
|
|||
|
||||
return 0 if points.length < 2
|
||||
|
||||
total_meters = points.each_cons(2).sum do |point1, point2|
|
||||
connection.select_value(
|
||||
'SELECT ST_Distance(ST_GeomFromEWKT($1)::geography, ST_GeomFromEWKT($2)::geography)',
|
||||
nil,
|
||||
[point1.lonlat, point2.lonlat]
|
||||
)
|
||||
end
|
||||
total_meters = calculate_batch_distances(points).sum
|
||||
|
||||
total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
||||
end
|
||||
|
||||
def calculate_batch_distances(points)
|
||||
return [] if points.length < 2
|
||||
|
||||
point_pairs = points.each_cons(2).to_a
|
||||
return [] if point_pairs.empty?
|
||||
|
||||
# Create parameterized placeholders for VALUES clause using ? placeholders
|
||||
values_placeholders = point_pairs.map do |_|
|
||||
"(?, ST_GeomFromEWKT(?)::geography, ST_GeomFromEWKT(?)::geography)"
|
||||
end.join(', ')
|
||||
|
||||
# Flatten parameters: [pair_id, lonlat1, lonlat2, pair_id, lonlat1, lonlat2, ...]
|
||||
params = point_pairs.flat_map.with_index do |(p1, p2), index|
|
||||
[index, p1.lonlat, p2.lonlat]
|
||||
end
|
||||
|
||||
# Single query to calculate all distances using parameterized query
|
||||
sql_with_params = ActiveRecord::Base.sanitize_sql_array([<<-SQL.squish] + params)
|
||||
WITH point_pairs AS (
|
||||
SELECT
|
||||
pair_id,
|
||||
point1,
|
||||
point2
|
||||
FROM (VALUES #{values_placeholders}) AS t(pair_id, point1, point2)
|
||||
)
|
||||
SELECT
|
||||
pair_id,
|
||||
ST_Distance(point1, point2) as distance_meters
|
||||
FROM point_pairs
|
||||
ORDER BY pair_id
|
||||
SQL
|
||||
|
||||
results = connection.select_all(sql_with_params)
|
||||
|
||||
# Return array of distances in meters
|
||||
results.map { |row| row['distance_meters'].to_f }
|
||||
end
|
||||
end
|
||||
|
||||
def distance_to(other_point, unit = :km)
|
||||
|
|
@ -67,7 +98,6 @@ module Distanceable
|
|||
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
# Extract coordinates based on what type other_point is
|
||||
other_lonlat = extract_point(other_point)
|
||||
return nil if other_lonlat.nil?
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class Point < ApplicationRecord
|
|||
index: true
|
||||
}
|
||||
|
||||
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true
|
||||
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4 }, suffix: true
|
||||
enum :trigger, {
|
||||
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
|
||||
report_location_message_event: 4, manual_event: 5, timer_based_event: 6,
|
||||
|
|
@ -70,6 +70,8 @@ class Point < ApplicationRecord
|
|||
|
||||
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
|
||||
def broadcast_coordinates
|
||||
return unless user.safe_settings.live_map_enabled
|
||||
|
||||
PointsChannel.broadcast_to(
|
||||
user,
|
||||
[
|
||||
|
|
|
|||
|
|
@ -25,6 +25,114 @@ class Track < ApplicationRecord
|
|||
.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
|
||||
|
|
@ -45,23 +153,7 @@ class Track < ApplicationRecord
|
|||
def broadcast_track_update(action)
|
||||
TracksChannel.broadcast_to(user, {
|
||||
action: action,
|
||||
track: serialize_track_data
|
||||
track: TrackSerializer.new(self).call
|
||||
})
|
||||
end
|
||||
|
||||
def serialize_track_data
|
||||
{
|
||||
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
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -81,8 +81,10 @@ class Gpx::TrackImporter
|
|||
def speed(point)
|
||||
return if point['extensions'].blank?
|
||||
|
||||
(
|
||||
point.dig('extensions', 'speed') || point.dig('extensions', 'TrackPointExtension', 'speed')
|
||||
).to_f.round(1)
|
||||
value = point.dig('extensions', 'speed')
|
||||
extensions = point.dig('extensions', 'TrackPointExtension')
|
||||
value ||= extensions.is_a?(Hash) ? extensions.dig('speed') : nil
|
||||
|
||||
value&.to_f&.round(1) || 0.0
|
||||
end
|
||||
end
|
||||
|
|
|
|||
34
app/services/prometheus_metrics.rb
Normal file
34
app/services/prometheus_metrics.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
|
||||
class PrometheusMetrics
|
||||
class << self
|
||||
def fetch_data
|
||||
return { success: false, error: 'Prometheus exporter not enabled' } unless prometheus_enabled?
|
||||
|
||||
host = ENV.fetch('PROMETHEUS_EXPORTER_HOST', 'localhost')
|
||||
port = ENV.fetch('PROMETHEUS_EXPORTER_PORT', 9394)
|
||||
|
||||
begin
|
||||
response = Net::HTTP.get_response(URI("http://#{host}:#{port}/metrics"))
|
||||
|
||||
if response.code == '200'
|
||||
{ success: true, data: response.body }
|
||||
else
|
||||
{ success: false, error: "Prometheus server returned #{response.code}" }
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to fetch Prometheus metrics: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prometheus_enabled?
|
||||
DawarichSettings.prometheus_exporter_enabled?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -40,12 +40,20 @@ class Tracks::Generator
|
|||
def call
|
||||
clean_existing_tracks if should_clean_tracks?
|
||||
|
||||
points = load_points
|
||||
Rails.logger.debug "Generator: loaded #{points.size} points for user #{user.id} in #{mode} mode"
|
||||
return 0 if points.empty?
|
||||
start_timestamp, end_timestamp = get_timestamp_range
|
||||
|
||||
segments = split_points_into_segments(points)
|
||||
Rails.logger.debug "Generator: created #{segments.size} segments"
|
||||
Rails.logger.debug "Generator: querying points for user #{user.id} in #{mode} mode"
|
||||
|
||||
segments = Track.get_segments_with_points(
|
||||
user.id,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
time_threshold_minutes,
|
||||
distance_threshold_meters,
|
||||
untracked_only: mode == :incremental
|
||||
)
|
||||
|
||||
Rails.logger.debug "Generator: created #{segments.size} segments via SQL"
|
||||
|
||||
tracks_created = 0
|
||||
|
||||
|
|
@ -99,11 +107,14 @@ class Tracks::Generator
|
|||
user.tracked_points.where(timestamp: day_range).order(:timestamp)
|
||||
end
|
||||
|
||||
def create_track_from_segment(segment)
|
||||
Rails.logger.debug "Generator: processing segment with #{segment.size} points"
|
||||
return unless segment.size >= 2
|
||||
def create_track_from_segment(segment_data)
|
||||
points = segment_data[:points]
|
||||
pre_calculated_distance = segment_data[:pre_calculated_distance]
|
||||
|
||||
track = create_track_from_points(segment)
|
||||
Rails.logger.debug "Generator: processing segment with #{points.size} points"
|
||||
return unless points.size >= 2
|
||||
|
||||
track = create_track_from_points(points, pre_calculated_distance)
|
||||
Rails.logger.debug "Generator: created track #{track&.id}"
|
||||
track
|
||||
end
|
||||
|
|
@ -171,7 +182,37 @@ class Tracks::Generator
|
|||
scope.destroy_all
|
||||
end
|
||||
|
||||
# Threshold methods from safe_settings
|
||||
def get_timestamp_range
|
||||
case mode
|
||||
when :bulk then bulk_timestamp_range
|
||||
when :daily then daily_timestamp_range
|
||||
when :incremental then incremental_timestamp_range
|
||||
else
|
||||
raise ArgumentError, "Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_timestamp_range
|
||||
return [start_at.to_i, end_at.to_i] if start_at && end_at
|
||||
|
||||
first_point = user.tracked_points.order(:timestamp).first
|
||||
last_point = user.tracked_points.order(:timestamp).last
|
||||
|
||||
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
|
||||
end
|
||||
|
||||
def daily_timestamp_range
|
||||
day = start_at&.to_date || Date.current
|
||||
[day.beginning_of_day.to_i, day.end_of_day.to_i]
|
||||
end
|
||||
|
||||
def incremental_timestamp_range
|
||||
first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first
|
||||
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
|
||||
|
||||
[first_point&.timestamp || 0, end_timestamp]
|
||||
end
|
||||
|
||||
def distance_threshold_meters
|
||||
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||
end
|
||||
|
|
|
|||
|
|
@ -86,11 +86,13 @@ module Tracks::Segmentation
|
|||
end
|
||||
|
||||
def calculate_km_distance_between_points(point1, point2)
|
||||
lat1, lon1 = point_coordinates(point1)
|
||||
lat2, lon2 = point_coordinates(point2)
|
||||
distance_meters = Point.connection.select_value(
|
||||
'SELECT ST_Distance(ST_GeomFromEWKT($1)::geography, ST_GeomFromEWKT($2)::geography)',
|
||||
nil,
|
||||
[point1.lonlat, point2.lonlat]
|
||||
)
|
||||
|
||||
# Use Geocoder to match behavior with frontend (same library used elsewhere in app)
|
||||
Geocoder::Calculations.distance_between([lat1, lon1], [lat2, lon2], units: :km)
|
||||
distance_meters.to_f / 1000.0 # Convert meters to kilometers
|
||||
end
|
||||
|
||||
def should_finalize_segment?(segment_points, grace_period_minutes = 5)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
module Tracks::TrackBuilder
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def create_track_from_points(points)
|
||||
def create_track_from_points(points, pre_calculated_distance)
|
||||
return nil if points.size < 2
|
||||
|
||||
track = Track.new(
|
||||
|
|
@ -59,17 +59,16 @@ module Tracks::TrackBuilder
|
|||
original_path: build_path(points)
|
||||
)
|
||||
|
||||
# Calculate track statistics
|
||||
track.distance = calculate_track_distance(points)
|
||||
track.duration = calculate_duration(points)
|
||||
track.distance = pre_calculated_distance.round
|
||||
track.duration = calculate_duration(points)
|
||||
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
||||
|
||||
# Calculate elevation statistics
|
||||
# Calculate elevation statistics (no DB queries needed)
|
||||
elevation_stats = calculate_elevation_stats(points)
|
||||
track.elevation_gain = elevation_stats[:gain]
|
||||
track.elevation_loss = elevation_stats[:loss]
|
||||
track.elevation_max = elevation_stats[:max]
|
||||
track.elevation_min = elevation_stats[:min]
|
||||
track.elevation_max = elevation_stats[:max]
|
||||
track.elevation_min = elevation_stats[:min]
|
||||
|
||||
if track.save
|
||||
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
|
||||
|
|
@ -77,7 +76,6 @@ module Tracks::TrackBuilder
|
|||
track
|
||||
else
|
||||
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
@ -101,6 +99,7 @@ module Tracks::TrackBuilder
|
|||
|
||||
# Speed in meters per second, then convert to km/h for storage
|
||||
speed_mps = distance_in_meters.to_f / duration_seconds
|
||||
|
||||
(speed_mps * 3.6).round(2) # m/s to km/h
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,23 @@
|
|||
<label class="modal-backdrop" for="speed_colored_routes_info">Close</label>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="live_map_enabled_info" class="modal-toggle" />
|
||||
<div class="modal focus:z-99" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Live map</h3>
|
||||
<p class="py-4">
|
||||
This checkbox will enable the live map.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
Uncheck this checkbox if you want to disable the live map.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
When the live map is enabled, the map will update in real-time with the latest points.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="live_map_enabled_info">Close</label>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="speed_color_scale_info" class="modal-toggle" />
|
||||
<div class="modal focus:z-99" role="dialog">
|
||||
<div class="modal-box">
|
||||
|
|
|
|||
|
|
@ -6,34 +6,34 @@
|
|||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
|
||||
<%= f.datetime_local_field :start_at, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
|
||||
<%= f.datetime_local_field :end_at, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :import, class: "text-sm font-semibold" %>
|
||||
<%= f.select :import_id, options_for_select(@imports.map { |i| [i.name, i.id] }, params[:import_id]), { include_blank: true }, class: "rounded-md w-full" %>
|
||||
<%= f.select :import_id, options_for_select(@imports.map { |i| [i.name, i.id] }, params[:import_id]), { include_blank: true }, class: "input input-bordered hover:cursor-pointer hover:input-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
<%= f.submit "Search", class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to 'Export as GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :json), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md join-item" %>
|
||||
<%= link_to 'Export as GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :json), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to 'Export as GPX', exports_path(start_at: @start_at, end_at: @end_at, file_format: :gpx), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md join-item" %>
|
||||
<%= link_to 'Export as GPX', exports_path(start_at: @start_at, end_at: @end_at, file_format: :gpx), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points within timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -46,9 +46,8 @@
|
|||
<div id="points" class="min-w-full">
|
||||
<div data-controller='checkbox-select-all'>
|
||||
<%= form_with url: bulk_destroy_points_path(params.permit!), method: :delete, id: :bulk_destroy_form do |f| %>
|
||||
|
||||
<div class="flex justify-between my-5">
|
||||
<%= f.submit "Delete Selected", class: "px-4 py-2 bg-red-500 text-white rounded-md", data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" } %>
|
||||
<%= f.submit "Delete Selected", class: "px-4 py-2 bg-red-500 text-white rounded-md", data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", checkbox_select_all_target: "deleteButton" }, style: "display: none;" %>
|
||||
<div>
|
||||
<%= page_entries_info @points, entry_name: 'point' %>
|
||||
</div>
|
||||
|
|
@ -64,14 +63,15 @@
|
|||
<tr>
|
||||
<th>
|
||||
<%= label_tag do %>
|
||||
Select all
|
||||
<%= check_box_tag 'Select all',
|
||||
id: :select_all_points,
|
||||
data: {
|
||||
checkbox_select_all_target: 'parent',
|
||||
action: 'change->checkbox-select-all#toggleChildren'
|
||||
}
|
||||
},
|
||||
class: 'mr-2'
|
||||
%>
|
||||
Select all
|
||||
<% end %>
|
||||
</div>
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
|
||||
<% if current_user.active? %>
|
||||
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
<% @stats.each do |year, stats| %>
|
||||
|
|
@ -33,13 +35,13 @@
|
|||
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
|
||||
</div>
|
||||
<div class="gap-2">
|
||||
<span class='text-xs text-gray-500'>Last updated: <%= human_date(stats.first.updated_at) %></span>
|
||||
<span class='text-xs text-gray-500'>Last update: <%= human_date(stats.first.updated_at) %></span>
|
||||
<%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
</div>
|
||||
</h2>
|
||||
<p>
|
||||
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
|
||||
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= current_user.safe_settings.distance_unit %>
|
||||
<%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="stat-title text-xs">Distance</div>
|
||||
<div class="stat-value text-lg"><%= trip.distance_for_user(current_user).round %> <%= distance_unit %></div>
|
||||
<div class="stat-value text-lg"><%= trip.distance_in_unit(distance_unit).round %> <%= distance_unit %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<% if trip.distance.present? %>
|
||||
<span class="text-md"><%= trip.distance_for_user(current_user).round %> <%= distance_unit %></span>
|
||||
<span class="text-md"><%= trip.distance_in_unit(distance_unit).round %> <%= distance_unit %></span>
|
||||
<% else %>
|
||||
<span class="text-md">Calculating...</span>
|
||||
<span class="loading loading-dots loading-sm"></span>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<span class="hover:underline"><%= trip.name %></span>
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance_for_user(current_user).round} #{current_user.safe_settings.distance_unit}" %>
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance_in_unit(current_user.safe_settings.distance_unit).round} #{distance_unit}" %>
|
||||
</p>
|
||||
|
||||
<div style="width: 100%; aspect-ratio: 1/1;"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
development:
|
||||
adapter: redis
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}" %>
|
||||
db: <%= "#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
|
||||
test:
|
||||
adapter: test
|
||||
|
||||
production:
|
||||
adapter: redis
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}" %>
|
||||
db: <%= "#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
channel_prefix: dawarich_production
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Rails.application.configure do
|
|||
|
||||
# Enable/disable caching. By default caching is disabled.
|
||||
# Run rails dev:cache to toggle caching.
|
||||
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
|
||||
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_CACHE_DB', 0) }
|
||||
|
||||
if Rails.root.join('tmp/caching-dev.txt').exist?
|
||||
config.action_controller.perform_caching = true
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ Rails.application.configure do
|
|||
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
|
||||
|
||||
# Use a different cache store in production.
|
||||
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
|
||||
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_CACHE_DB', 0) }
|
||||
|
||||
# Use a real queuing backend for Active Job (and separate queues per environment).
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
|
|
|
|||
|
|
@ -31,3 +31,8 @@ STORE_GEODATA = ENV.fetch('STORE_GEODATA', 'true') == 'true'
|
|||
|
||||
SENTRY_DSN = ENV.fetch('SENTRY_DSN', nil)
|
||||
MANAGER_URL = SELF_HOSTED ? nil : ENV.fetch('MANAGER_URL', nil)
|
||||
|
||||
# Prometheus metrics
|
||||
METRICS_USERNAME = ENV.fetch('METRICS_USERNAME', 'prometheus')
|
||||
METRICS_PASSWORD = ENV.fetch('METRICS_PASSWORD', 'prometheus')
|
||||
# /Prometheus metrics
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ settings = {
|
|||
debug_mode: true,
|
||||
timeout: 5,
|
||||
units: :km,
|
||||
cache: Redis.new(url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}"),
|
||||
cache: Redis.new(url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_CACHE_DB', 0)),
|
||||
always_raise: :all,
|
||||
http_headers: {
|
||||
'User-Agent' => "Dawarich #{APP_VERSION} (https://dawarich.app)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_JOB_QUEUE_DB', 1)}" }
|
||||
config.redis = { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_JOB_QUEUE_DB', 1) }
|
||||
config.logger = Sidekiq::Logger.new($stdout)
|
||||
|
||||
if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'
|
||||
|
|
@ -24,7 +24,7 @@ Sidekiq.configure_server do |config|
|
|||
end
|
||||
|
||||
Sidekiq.configure_client do |config|
|
||||
config.redis = { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_JOB_QUEUE_DB', 1)}" }
|
||||
config.redis = { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_JOB_QUEUE_DB', 1) }
|
||||
end
|
||||
|
||||
Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && DawarichSettings.photon_uses_komoot_io?
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ Rails.application.routes.draw do
|
|||
devise_for :users
|
||||
end
|
||||
|
||||
resources :metrics, only: [:index]
|
||||
|
||||
get 'map', to: 'map#index'
|
||||
|
||||
namespace :api do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
class AddTrackGenerationCompositeIndex < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :points, [:user_id, :timestamp, :track_id],
|
||||
algorithm: :concurrently,
|
||||
name: 'idx_points_track_generation', if_not_exists: true
|
||||
end
|
||||
end
|
||||
3
db/schema.rb
generated
3
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_07_21_204404) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_07_23_164055) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -203,6 +203,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_21_204404) do
|
|||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||
t.index ["track_id"], name: "index_points_on_track_id"
|
||||
t.index ["trigger"], name: "index_points_on_trigger"
|
||||
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
|
||||
t.index ["user_id"], name: "index_points_on_user_id"
|
||||
t.index ["visit_id"], name: "index_points_on_visit_id"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Timestamps
|
||||
|
||||
def self.parse_timestamp(timestamp)
|
||||
begin
|
||||
# if the timestamp is in ISO 8601 format, try to parse it
|
||||
|
|
|
|||
|
|
@ -63,5 +63,14 @@ RSpec.describe '/points', type: :request do
|
|||
|
||||
expect(response).to redirect_to(points_url(start_at: '2021-01-01', end_at: '2021-01-02'))
|
||||
end
|
||||
|
||||
context 'when no points are selected' do
|
||||
it 'redirects to the points list' do
|
||||
delete bulk_destroy_points_url, params: { point_ids: [] }
|
||||
|
||||
expect(response).to redirect_to(points_url)
|
||||
expect(flash[:alert]).to eq('No points selected.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,21 +39,23 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
]
|
||||
end
|
||||
|
||||
let(:pre_calculated_distance) { 1500 } # 1500 meters
|
||||
|
||||
it 'creates a track with correct attributes' do
|
||||
track = builder.create_track_from_points(points)
|
||||
track = builder.create_track_from_points(points, pre_calculated_distance)
|
||||
|
||||
expect(track).to be_persisted
|
||||
expect(track.user).to eq(user)
|
||||
expect(track.start_at).to be_within(1.second).of(Time.zone.at(points.first.timestamp))
|
||||
expect(track.end_at).to be_within(1.second).of(Time.zone.at(points.last.timestamp))
|
||||
expect(track.distance).to be > 0
|
||||
expect(track.distance).to eq(1500)
|
||||
expect(track.duration).to eq(90.minutes.to_i)
|
||||
expect(track.avg_speed).to be > 0
|
||||
expect(track.original_path).to be_present
|
||||
end
|
||||
|
||||
it 'calculates elevation statistics correctly' do
|
||||
track = builder.create_track_from_points(points)
|
||||
track = builder.create_track_from_points(points, pre_calculated_distance)
|
||||
|
||||
expect(track.elevation_gain).to eq(10) # 110 - 100
|
||||
expect(track.elevation_loss).to eq(5) # 110 - 105
|
||||
|
|
@ -62,7 +64,7 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
end
|
||||
|
||||
it 'associates points with the track' do
|
||||
track = builder.create_track_from_points(points)
|
||||
track = builder.create_track_from_points(points, pre_calculated_distance)
|
||||
|
||||
points.each(&:reload)
|
||||
expect(points.map(&:track)).to all(eq(track))
|
||||
|
|
@ -73,12 +75,12 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
let(:single_point) { [create(:point, user: user)] }
|
||||
|
||||
it 'returns nil for single point' do
|
||||
result = builder.create_track_from_points(single_point)
|
||||
result = builder.create_track_from_points(single_point, 1000)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for empty array' do
|
||||
result = builder.create_track_from_points([])
|
||||
result = builder.create_track_from_points([], 1000)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
|
@ -100,7 +102,7 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
/Failed to create track for user #{user.id}/
|
||||
)
|
||||
|
||||
result = builder.create_track_from_points(points)
|
||||
result = builder.create_track_from_points(points, 1000)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
|
@ -120,7 +122,7 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
).and_call_original
|
||||
|
||||
result = builder.build_path(points)
|
||||
expect(result).to respond_to(:as_text)
|
||||
expect(result).to be_a(RGeo::Geographic::SphericalLineStringImpl)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -134,7 +136,7 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
|
||||
before do
|
||||
# Mock Point.total_distance to return distance in meters
|
||||
allow(Point).to receive(:total_distance).and_return(1500) # 1500 meters
|
||||
allow(Point).to receive(:total_distance).with(points, :m).and_return(1500) # 1500 meters
|
||||
end
|
||||
|
||||
it 'stores distance in meters regardless of user unit preference' do
|
||||
|
|
@ -143,7 +145,7 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
end
|
||||
|
||||
it 'rounds distance to nearest meter' do
|
||||
allow(Point).to receive(:total_distance).and_return(1500.7)
|
||||
allow(Point).to receive(:total_distance).with(points, :m).and_return(1500.7)
|
||||
result = builder.calculate_track_distance(points)
|
||||
expect(result).to eq(1501) # Rounded to nearest meter
|
||||
end
|
||||
|
|
@ -312,13 +314,15 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
]
|
||||
end
|
||||
|
||||
let(:pre_calculated_distance) { 2000 }
|
||||
|
||||
it 'creates a complete track end-to-end' do
|
||||
expect { builder.create_track_from_points(points) }.to change(Track, :count).by(1)
|
||||
expect { builder.create_track_from_points(points, pre_calculated_distance) }.to change(Track, :count).by(1)
|
||||
|
||||
track = Track.last
|
||||
expect(track.user).to eq(user)
|
||||
expect(track.points).to match_array(points)
|
||||
expect(track.distance).to be > 0
|
||||
expect(track.distance).to eq(2000)
|
||||
expect(track.duration).to eq(1.hour.to_i)
|
||||
expect(track.elevation_gain).to eq(20)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -435,7 +435,7 @@ RSpec.describe 'Map Interaction', type: :system do
|
|||
end
|
||||
end
|
||||
|
||||
context 'settings panel functionality' do
|
||||
xcontext 'settings panel functionality' do
|
||||
include_context 'authenticated map user'
|
||||
|
||||
it 'allows updating route opacity settings' do
|
||||
|
|
|
|||
Loading…
Reference in a new issue