mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Compare commits
5 commits
41ab6b5532
...
b5aaaffb67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5aaaffb67 | ||
|
|
b3e8155e43 | ||
|
|
f4605989b6 | ||
|
|
6dd048cee3 | ||
|
|
f1720b859b |
31 changed files with 266 additions and 206 deletions
|
|
@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Don't check for new version in production.
|
||||
- Area popup styles are now more consistent.
|
||||
- Notification about Photon API load is now disabled.
|
||||
- All distance values are now stored in the database in meters. Conversion to user's preferred unit is done on the fly.
|
||||
|
||||
## Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -35,22 +35,16 @@ class MapController < ApplicationController
|
|||
end
|
||||
|
||||
def calculate_distance
|
||||
distance = 0
|
||||
total_distance_meters = 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
|
||||
distance_km = Geocoder::Calculations.distance_between(
|
||||
[_1[0], _1[1]], [_2[0], _2[1]], units: :km
|
||||
)
|
||||
total_distance_meters += distance_km * 1000 # Convert km to meters
|
||||
end
|
||||
|
||||
distance_in_meters = case current_user.safe_settings.distance_unit.to_s
|
||||
when 'mi'
|
||||
distance * 1609.344 # miles to meters
|
||||
else
|
||||
distance * 1000 # km to meters
|
||||
end
|
||||
|
||||
distance_in_meters.round
|
||||
total_distance_meters.round
|
||||
end
|
||||
|
||||
def parsed_start_at
|
||||
|
|
|
|||
|
|
@ -76,8 +76,9 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def year_distance_stat(year, user)
|
||||
# In km or miles, depending on the user.safe_settings.distance_unit
|
||||
Stat.year_distance(year, user).sum { _1[1] }
|
||||
# Distance is now stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def past?(year, month)
|
||||
|
|
@ -98,10 +99,13 @@ module ApplicationHelper
|
|||
current_user&.theme == 'light' ? 'light' : 'dark'
|
||||
end
|
||||
|
||||
def sidebar_distance(distance)
|
||||
return unless distance
|
||||
def sidebar_distance(distance_meters)
|
||||
return unless distance_meters
|
||||
|
||||
"#{distance} #{current_user.safe_settings.distance_unit}"
|
||||
# Convert from stored meters to user's preferred unit for display
|
||||
user_unit = current_user.safe_settings.distance_unit
|
||||
converted_distance = Stat.convert_distance(distance_meters, user_unit)
|
||||
"#{converted_distance.round(2)} #{user_unit}"
|
||||
end
|
||||
|
||||
def sidebar_points(points)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ module Calculateable
|
|||
end
|
||||
|
||||
def calculate_distance
|
||||
calculated_distance = calculate_distance_from_coordinates
|
||||
self.distance = convert_distance_for_storage(calculated_distance)
|
||||
calculated_distance_meters = calculate_distance_from_coordinates
|
||||
self.distance = convert_distance_for_storage(calculated_distance_meters)
|
||||
end
|
||||
|
||||
def recalculate_path!
|
||||
|
|
@ -44,16 +44,14 @@ module Calculateable
|
|||
self.original_path = updated_path if respond_to?(:original_path=)
|
||||
end
|
||||
|
||||
def user_distance_unit
|
||||
user.safe_settings.distance_unit
|
||||
end
|
||||
|
||||
def calculate_distance_from_coordinates
|
||||
Point.total_distance(points, user_distance_unit)
|
||||
# Always calculate in meters for consistent storage
|
||||
Point.total_distance(points, :m)
|
||||
end
|
||||
|
||||
def convert_distance_for_storage(calculated_distance)
|
||||
calculated_distance.round(2)
|
||||
def convert_distance_for_storage(calculated_distance_meters)
|
||||
# Store as integer meters for consistency
|
||||
calculated_distance_meters.round
|
||||
end
|
||||
|
||||
def track_model?
|
||||
|
|
|
|||
75
app/models/concerns/distance_convertible.rb
Normal file
75
app/models/concerns/distance_convertible.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Module for converting distances from stored meters to user's preferred unit at runtime.
|
||||
#
|
||||
# All distances are stored in meters in the database for consistency. This module provides
|
||||
# methods to convert those stored meter values to the user's preferred unit (km, mi, etc.)
|
||||
# for display purposes.
|
||||
#
|
||||
# This approach ensures:
|
||||
# - Consistent data storage regardless of user preferences
|
||||
# - No data corruption when users change distance units
|
||||
# - Easy conversion for display without affecting stored data
|
||||
#
|
||||
# Usage:
|
||||
# class Track < ApplicationRecord
|
||||
# include DistanceConvertible
|
||||
# end
|
||||
#
|
||||
# track.distance # => 5000 (meters stored in DB)
|
||||
# track.distance_in_unit('km') # => 5.0 (converted to km)
|
||||
# track.distance_in_unit('mi') # => 3.11 (converted to miles)
|
||||
# track.formatted_distance('km') # => "5.0 km"
|
||||
#
|
||||
module DistanceConvertible
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def distance_in_unit(unit)
|
||||
return 0.0 unless distance.present?
|
||||
|
||||
unit_sym = unit.to_sym
|
||||
conversion_factor = ::DISTANCE_UNITS[unit_sym]
|
||||
|
||||
unless conversion_factor
|
||||
raise ArgumentError, "Invalid unit '#{unit}'. Supported units: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
# Distance is stored in meters, convert to target unit
|
||||
distance.to_f / conversion_factor
|
||||
end
|
||||
|
||||
def formatted_distance(unit, precision: 2)
|
||||
converted_distance = distance_in_unit(unit)
|
||||
"#{converted_distance.round(precision)} #{unit}"
|
||||
end
|
||||
|
||||
def distance_for_user(user)
|
||||
user_unit = user.safe_settings.distance_unit
|
||||
distance_in_unit(user_unit)
|
||||
end
|
||||
|
||||
def formatted_distance_for_user(user, precision: 2)
|
||||
user_unit = user.safe_settings.distance_unit
|
||||
formatted_distance(user_unit, precision: precision)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def convert_distance(distance_meters, unit)
|
||||
return 0.0 unless distance_meters.present?
|
||||
|
||||
unit_sym = unit.to_sym
|
||||
conversion_factor = ::DISTANCE_UNITS[unit_sym]
|
||||
|
||||
unless conversion_factor
|
||||
raise ArgumentError, "Invalid unit '#{unit}'. Supported units: #{::DISTANCE_UNITS.keys.join(', ')}"
|
||||
end
|
||||
|
||||
distance_meters.to_f / conversion_factor
|
||||
end
|
||||
|
||||
def format_distance(distance_meters, unit, precision: 2)
|
||||
converted = convert_distance(distance_meters, unit)
|
||||
"#{converted.round(precision)} #{unit}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -117,7 +117,7 @@ class Point < ApplicationRecord
|
|||
|
||||
def trigger_incremental_track_generation
|
||||
point_date = Time.zone.at(timestamp).to_date
|
||||
return unless point_date >= 1.day.ago.to_date
|
||||
return if point_date < 1.day.ago.to_date
|
||||
|
||||
Tracks::IncrementalGeneratorJob.perform_later(user_id, point_date.to_s, 5)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stat < ApplicationRecord
|
||||
include DistanceConvertible
|
||||
|
||||
validates :year, :month, presence: true
|
||||
|
||||
belongs_to :user
|
||||
|
|
@ -37,8 +39,9 @@ class Stat < ApplicationRecord
|
|||
def calculate_daily_distances(monthly_points)
|
||||
timespan.to_a.map.with_index(1) do |day, index|
|
||||
daily_points = filter_points_for_day(monthly_points, day)
|
||||
distance = Point.total_distance(daily_points, user.safe_settings.distance_unit)
|
||||
[index, distance.round]
|
||||
# Calculate distance in meters for consistent storage
|
||||
distance_meters = Point.total_distance(daily_points, :m)
|
||||
[index, distance_meters.round]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Track < ApplicationRecord
|
||||
include Calculateable
|
||||
include DistanceConvertible
|
||||
|
||||
belongs_to :user
|
||||
has_many :points, dependent: :nullify
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Trip < ApplicationRecord
|
||||
include Calculateable
|
||||
include DistanceConvertible
|
||||
|
||||
has_rich_text :notes
|
||||
|
||||
|
|
|
|||
|
|
@ -50,8 +50,9 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def total_distance
|
||||
# In km or miles, depending on user.safe_settings.distance_unit
|
||||
stats.sum(:distance)
|
||||
# Distance is stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = stats.sum(:distance)
|
||||
Stat.convert_distance(total_distance_meters, safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def total_countries
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class StatsSerializer
|
|||
|
||||
def call
|
||||
{
|
||||
totalDistanceKm: total_distance,
|
||||
totalDistanceKm: total_distance_km,
|
||||
totalPointsTracked: user.tracked_points.count,
|
||||
totalReverseGeocodedPoints: reverse_geocoded_points,
|
||||
totalCountriesVisited: user.countries_visited.count,
|
||||
|
|
@ -20,8 +20,10 @@ class StatsSerializer
|
|||
|
||||
private
|
||||
|
||||
def total_distance
|
||||
user.stats.sum(:distance)
|
||||
def total_distance_km
|
||||
total_distance_meters = user.stats.sum(:distance)
|
||||
|
||||
(total_distance_meters / 1000)
|
||||
end
|
||||
|
||||
def reverse_geocoded_points
|
||||
|
|
@ -32,7 +34,7 @@ class StatsSerializer
|
|||
user.stats.group_by(&:year).sort.reverse.map do |year, stats|
|
||||
{
|
||||
year:,
|
||||
totalDistanceKm: stats.sum(&:distance),
|
||||
totalDistanceKm: stats_distance_km(stats),
|
||||
totalCountriesVisited: user.countries_visited.count,
|
||||
totalCitiesVisited: user.cities_visited.count,
|
||||
monthlyDistanceKm: monthly_distance(year, stats)
|
||||
|
|
@ -40,15 +42,24 @@ class StatsSerializer
|
|||
end
|
||||
end
|
||||
|
||||
def stats_distance_km(stats)
|
||||
# Convert from stored meters to kilometers
|
||||
total_meters = stats.sum(&:distance)
|
||||
total_meters / 1000
|
||||
end
|
||||
|
||||
def monthly_distance(year, stats)
|
||||
months = {}
|
||||
|
||||
(1..12).each { |month| months[Date::MONTHNAMES[month]&.downcase] = distance(month, year, stats) }
|
||||
(1..12).each { |month| months[Date::MONTHNAMES[month]&.downcase] = distance_km(month, year, stats) }
|
||||
|
||||
months
|
||||
end
|
||||
|
||||
def distance(month, year, stats)
|
||||
stats.find { _1.month == month && _1.year == year }&.distance.to_i
|
||||
def distance_km(month, year, stats)
|
||||
# Convert from stored meters to kilometers
|
||||
distance_meters = stats.find { _1.month == month && _1.year == year }&.distance.to_i
|
||||
|
||||
distance_meters / 1000
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class Gpx::TrackImporter
|
|||
{
|
||||
lonlat: "POINT(#{point['lon'].to_d} #{point['lat'].to_d})",
|
||||
altitude: point['ele'].to_i,
|
||||
timestamp: Point.normalize_timestamp(point['time']),
|
||||
timestamp: Time.parse(point['time']).to_i,
|
||||
import_id: import.id,
|
||||
velocity: speed(point),
|
||||
raw_data: point,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class Overland::Params
|
|||
lonlat: "POINT(#{point[:geometry][:coordinates][0]} #{point[:geometry][:coordinates][1]})",
|
||||
battery_status: point[:properties][:battery_state],
|
||||
battery: battery_level(point[:properties][:battery_level]),
|
||||
timestamp: Point.normalize_timestamp(point[:properties][:timestamp]),
|
||||
timestamp: DateTime.parse(point[:properties][:timestamp]),
|
||||
altitude: point[:properties][:altitude],
|
||||
velocity: point[:properties][:speed],
|
||||
tracker_id: point[:properties][:device_id],
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class Points::Params
|
|||
lonlat: lonlat(point),
|
||||
battery_status: point[:properties][:battery_state],
|
||||
battery: battery_level(point[:properties][:battery_level]),
|
||||
timestamp: normalize_timestamp(point[:properties][:timestamp]),
|
||||
timestamp: DateTime.parse(point[:properties][:timestamp]),
|
||||
altitude: point[:properties][:altitude],
|
||||
tracker_id: point[:properties][:device_id],
|
||||
velocity: point[:properties][:speed],
|
||||
|
|
@ -48,8 +48,4 @@ class Points::Params
|
|||
def lonlat(point)
|
||||
"POINT(#{point[:geometry][:coordinates][0]} #{point[:geometry][:coordinates][1]})"
|
||||
end
|
||||
|
||||
def normalize_timestamp(timestamp)
|
||||
Point.normalize_timestamp(DateTime.parse(timestamp))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@
|
|||
# 6. Associates all points with the created track
|
||||
#
|
||||
# Statistics calculated:
|
||||
# - Distance: In user's preferred unit (km/miles) with 2 decimal precision
|
||||
# - Distance: Always stored in meters as integers for consistency
|
||||
# - 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.
|
||||
# Distance is converted to user's preferred unit only at display time, not storage time.
|
||||
# This ensures consistency when users change their distance unit preferences.
|
||||
#
|
||||
# Used by:
|
||||
# - Tracks::Generator for creating tracks during generation
|
||||
|
|
@ -71,11 +71,12 @@ module Tracks::TrackBuilder
|
|||
track.elevation_max = elevation_stats[:max]
|
||||
track.elevation_min = elevation_stats[:min]
|
||||
|
||||
if track.save!
|
||||
if track.save
|
||||
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
|
||||
track
|
||||
else
|
||||
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
@ -85,27 +86,20 @@ module Tracks::TrackBuilder
|
|||
end
|
||||
|
||||
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)
|
||||
# Always calculate and store distance in meters for consistency
|
||||
distance_in_meters = Point.total_distance(points, :m)
|
||||
distance_in_meters.round
|
||||
end
|
||||
|
||||
def calculate_duration(points)
|
||||
points.last.timestamp - points.first.timestamp
|
||||
end
|
||||
|
||||
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 'mi'
|
||||
distance_in_user_unit * 1609.344 # miles to meters
|
||||
else
|
||||
distance_in_user_unit * 1000 # km to meters
|
||||
end
|
||||
def calculate_average_speed(distance_in_meters, duration_seconds)
|
||||
return 0.0 if duration_seconds <= 0 || distance_in_meters <= 0
|
||||
|
||||
# Speed in meters per second, then convert to km/h for storage
|
||||
speed_mps = distance_meters.to_f / duration_seconds
|
||||
speed_mps = distance_in_meters.to_f / duration_seconds
|
||||
(speed_mps * 3.6).round(2) # m/s to km/h
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,28 @@
|
|||
<div id="<%= dom_id stat %>" class="card w-full bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">
|
||||
<%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
|
||||
<%= Date::MONTHNAMES[stat.month] %>
|
||||
<% end %>
|
||||
</h2>
|
||||
<div class="border border-gray-500 rounded-md border-opacity-30 bg-gray-100 dark:bg-gray-800 p-3">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="stat-title text-left"><%= Date::MONTHNAMES[stat.month] %> <%= stat.year %></h4>
|
||||
|
||||
<div class="gap-2">
|
||||
<span class='text-xs text-gray-500'>Last update <%= human_date(stat.updated_at) %></span>
|
||||
<%= link_to '🔄', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to "Details", points_path(year: stat.year, month: stat.month),
|
||||
class: "link link-primary" %>
|
||||
</div>
|
||||
<p><%= number_with_delimiter stat.distance %><%= current_user.safe_settings.distance_unit %></p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<div class="card-actions justify-end">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if stat.daily_distance %>
|
||||
<%= column_chart(
|
||||
stat.daily_distance,
|
||||
height: '100px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="stat-value">
|
||||
<p><%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-desc">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
|
||||
<canvas id="distance-chart-<%= stat.id %>"
|
||||
data-daily-distance="<%= stat.daily_distance %>"
|
||||
data-distance-type="monthly"
|
||||
data-title="<%= Date::MONTHNAMES[stat.month] %> <%= stat.year %>"
|
||||
data-y-axis-title="Distance"
|
||||
suffix: " <%= current_user.safe_settings.distance_unit %>",
|
||||
data-user-settings="<%= current_user.safe_settings.default_settings.to_json %>"></canvas>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,9 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<%= column_chart(
|
||||
Stat.year_distance(year, current_user),
|
||||
Stat.year_distance(year, current_user).map { |month_name, distance_meters|
|
||||
[month_name, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round(2)]
|
||||
},
|
||||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
|
|
|
|||
|
|
@ -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 %> <%= distance_unit %></div>
|
||||
<div class="stat-value text-lg"><%= trip.distance_for_user(current_user).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 %> <%= distance_unit %></span>
|
||||
<span class="text-md"><%= trip.distance_for_user(current_user).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} #{current_user.safe_settings.distance_unit}" %>
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance_for_user(current_user).round} #{current_user.safe_settings.distance_unit}" %>
|
||||
</p>
|
||||
|
||||
<div style="width: 100%; aspect-ratio: 1/1;"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ FactoryBot.define do
|
|||
factory :stat do
|
||||
year { 1 }
|
||||
month { 1 }
|
||||
distance { 1 }
|
||||
distance { 1000 } # 1 km
|
||||
user
|
||||
toponyms do
|
||||
[
|
||||
|
|
|
|||
|
|
@ -133,11 +133,13 @@ RSpec.describe Point, type: :model do
|
|||
end
|
||||
|
||||
describe '#trigger_incremental_track_generation' do
|
||||
let(:point) { create(:point, track: track) }
|
||||
let(:point) do
|
||||
create(:point, track: track, import_id: nil, timestamp: 1.hour.ago.to_i, reverse_geocoded_at: 1.hour.ago)
|
||||
end
|
||||
let(:track) { create(:track) }
|
||||
|
||||
it 'enqueues Tracks::IncrementalGeneratorJob' do
|
||||
expect { point.trigger_incremental_track_generation }.to have_enqueued_job(Tracks::IncrementalGeneratorJob)
|
||||
expect { point.send(:trigger_incremental_track_generation) }.to have_enqueued_job(Tracks::IncrementalGeneratorJob).with(point.user_id, point.recorded_at.to_date.to_s, 5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ RSpec.describe Stat, type: :model do
|
|||
create(:point, user:, lonlat: 'POINT(2 2)', timestamp: DateTime.new(year, 1, 1, 2))
|
||||
end
|
||||
|
||||
before { expected_distance[0][1] = 157 }
|
||||
before { expected_distance[0][1] = 156_876 }
|
||||
|
||||
it 'returns distance by day' do
|
||||
expect(subject).to eq(expected_distance)
|
||||
|
|
|
|||
|
|
@ -146,13 +146,12 @@ RSpec.describe Track, type: :model do
|
|||
expect(track.distance).to be_a(Numeric)
|
||||
end
|
||||
|
||||
it 'stores distance in user preferred unit for Track model' do
|
||||
allow(user).to receive(:safe_settings).and_return(double(distance_unit: 'km'))
|
||||
allow(Point).to receive(:total_distance).and_return(1.5) # 1.5 km
|
||||
it 'stores distance in meters consistently' do
|
||||
allow(Point).to receive(:total_distance).and_return(1500) # 1500 meters
|
||||
|
||||
track.calculate_distance
|
||||
|
||||
expect(track.distance).to eq(1.5) # Should be 1.5 km with 2 decimal places precision
|
||||
expect(track.distance).to eq(1500) # Should be stored as meters regardless of user unit preference
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -88,11 +88,11 @@ RSpec.describe User, type: :model do
|
|||
describe '#total_distance' do
|
||||
subject { user.total_distance }
|
||||
|
||||
let!(:stat1) { create(:stat, user:, distance: 10) }
|
||||
let!(:stat2) { create(:stat, user:, distance: 20) }
|
||||
let!(:stat1) { create(:stat, user:, distance: 10_000) }
|
||||
let!(:stat2) { create(:stat, user:, distance: 20_000) }
|
||||
|
||||
it 'returns sum of distances' do
|
||||
expect(subject).to eq(30)
|
||||
expect(subject).to eq(30) # 30 km
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
|
|||
end
|
||||
let(:expected_json) do
|
||||
{
|
||||
totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
|
||||
totalDistanceKm: (stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum) / 1000,
|
||||
totalPointsTracked: points_in_2020.count + points_in_2021.count,
|
||||
totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count,
|
||||
totalCountriesVisited: 1,
|
||||
|
|
@ -29,7 +29,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
|
|||
yearlyStats: [
|
||||
{
|
||||
year: 2021,
|
||||
totalDistanceKm: 12,
|
||||
totalDistanceKm: (stats_in_2021.map(&:distance).sum / 1000).to_i,
|
||||
totalCountriesVisited: 1,
|
||||
totalCitiesVisited: 1,
|
||||
monthlyDistanceKm: {
|
||||
|
|
@ -49,7 +49,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
|
|||
},
|
||||
{
|
||||
year: 2020,
|
||||
totalDistanceKm: 12,
|
||||
totalDistanceKm: (stats_in_2020.map(&:distance).sum / 1000).to_i,
|
||||
totalCountriesVisited: 1,
|
||||
totalCitiesVisited: 1,
|
||||
monthlyDistanceKm: {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ RSpec.describe StatsSerializer do
|
|||
end
|
||||
let(:expected_json) do
|
||||
{
|
||||
"totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
|
||||
"totalDistanceKm": (stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum) / 1000,
|
||||
"totalPointsTracked": points_in_2020.count + points_in_2021.count,
|
||||
"totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count,
|
||||
"totalCountriesVisited": 1,
|
||||
|
|
@ -48,7 +48,7 @@ RSpec.describe StatsSerializer do
|
|||
"yearlyStats": [
|
||||
{
|
||||
"year": 2021,
|
||||
"totalDistanceKm": 12,
|
||||
"totalDistanceKm": (stats_in_2021.map(&:distance).sum / 1000).to_i,
|
||||
"totalCountriesVisited": 1,
|
||||
"totalCitiesVisited": 1,
|
||||
"monthlyDistanceKm": {
|
||||
|
|
@ -68,7 +68,7 @@ RSpec.describe StatsSerializer do
|
|||
},
|
||||
{
|
||||
"year": 2020,
|
||||
"totalDistanceKm": 12,
|
||||
"totalDistanceKm": (stats_in_2020.map(&:distance).sum / 1000).to_i,
|
||||
"totalCountriesVisited": 1,
|
||||
"totalCitiesVisited": 1,
|
||||
"monthlyDistanceKm": {
|
||||
|
|
|
|||
|
|
@ -53,15 +53,17 @@ RSpec.describe Stats::CalculateMonth do
|
|||
lonlat: 'POINT(9.77973105800526 52.72859111523629)')
|
||||
end
|
||||
|
||||
context 'when units are kilometers' do
|
||||
context 'when calculating distance' do
|
||||
it 'creates stats' do
|
||||
expect { calculate_stats }.to change { Stat.count }.by(1)
|
||||
end
|
||||
|
||||
it 'calculates distance' do
|
||||
it 'calculates distance in meters consistently' do
|
||||
calculate_stats
|
||||
|
||||
expect(user.stats.last.distance).to eq(340)
|
||||
# Distance should be calculated in meters regardless of user unit preference
|
||||
# The actual distance between the test points is approximately 340 km = 340,000 meters
|
||||
expect(user.stats.last.distance).to be_within(1000).of(340_000)
|
||||
end
|
||||
|
||||
context 'when there is an error' do
|
||||
|
|
@ -79,33 +81,16 @@ RSpec.describe Stats::CalculateMonth do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when units are miles' do
|
||||
context 'when user prefers miles' do
|
||||
before do
|
||||
user.update(settings: { maps: { distance_unit: 'mi' } })
|
||||
end
|
||||
|
||||
it 'creates stats' do
|
||||
expect { calculate_stats }.to change { Stat.count }.by(1)
|
||||
end
|
||||
|
||||
it 'calculates distance' do
|
||||
it 'still stores distance in meters (same as km users)' do
|
||||
calculate_stats
|
||||
|
||||
expect(user.stats.last.distance).to eq(211)
|
||||
end
|
||||
|
||||
context 'when there is an error' do
|
||||
before do
|
||||
allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError)
|
||||
end
|
||||
|
||||
it 'does not create stats' do
|
||||
expect { calculate_stats }.not_to(change { Stat.count })
|
||||
end
|
||||
|
||||
it 'creates a notification' do
|
||||
expect { calculate_stats }.to change { Notification.count }.by(1)
|
||||
end
|
||||
# Distance stored should be the same regardless of user preference (meters)
|
||||
expect(user.stats.last.distance).to be_within(1000).of(340_000)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -270,24 +270,9 @@ RSpec.describe Tracks::CreateFromPoints do
|
|||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Point).to receive(:total_distance).and_return(1.5) # 1.5 km
|
||||
end
|
||||
|
||||
it 'stores distance in km by default' do
|
||||
it 'stores distance in meters by default' do
|
||||
distance = service.send(:calculate_track_distance, points)
|
||||
expect(distance).to eq(1.5) # 1.5 km with 2 decimal places precision
|
||||
end
|
||||
|
||||
context 'with miles unit' do
|
||||
before do
|
||||
user.update!(settings: user.settings.merge({'maps' => {'distance_unit' => 'miles'}}))
|
||||
end
|
||||
|
||||
it 'stores distance in miles' do
|
||||
distance = service.send(:calculate_track_distance, points)
|
||||
expect(distance).to eq(1.5) # 1.5 miles with 2 decimal places precision
|
||||
end
|
||||
expect(distance).to eq(87)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -132,40 +132,20 @@ RSpec.describe Tracks::TrackBuilder do
|
|||
]
|
||||
end
|
||||
|
||||
context 'with km unit' do
|
||||
before do
|
||||
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('km')
|
||||
allow(Point).to receive(:total_distance).and_return(1.5) # 1.5 km
|
||||
end
|
||||
|
||||
it 'stores distance in km' do
|
||||
result = builder.calculate_track_distance(points)
|
||||
expect(result).to eq(1.5) # 1.5 km with 2 decimal places precision
|
||||
end
|
||||
before do
|
||||
# Mock Point.total_distance to return distance in meters
|
||||
allow(Point).to receive(:total_distance).and_return(1500) # 1500 meters
|
||||
end
|
||||
|
||||
context 'with miles unit' do
|
||||
before do
|
||||
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('miles')
|
||||
allow(Point).to receive(:total_distance).and_return(1.0) # 1 mile
|
||||
end
|
||||
|
||||
it 'stores distance in miles' do
|
||||
result = builder.calculate_track_distance(points)
|
||||
expect(result).to eq(1) # 1 mile
|
||||
end
|
||||
it 'stores distance in meters regardless of user unit preference' do
|
||||
result = builder.calculate_track_distance(points)
|
||||
expect(result).to eq(1500) # Always stored as meters
|
||||
end
|
||||
|
||||
context 'with nil distance unit (defaults to km)' do
|
||||
before do
|
||||
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return(nil)
|
||||
allow(Point).to receive(:total_distance).and_return(2.0)
|
||||
end
|
||||
|
||||
it 'defaults to km and stores distance in km' do
|
||||
result = builder.calculate_track_distance(points)
|
||||
expect(result).to eq(2.0) # 2.0 km with 2 decimal places precision
|
||||
end
|
||||
it 'rounds distance to nearest meter' do
|
||||
allow(Point).to receive(:total_distance).and_return(1500.7)
|
||||
result = builder.calculate_track_distance(points)
|
||||
expect(result).to eq(1501) # Rounded to nearest meter
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,29 +8,7 @@ RSpec.describe Visits::Suggest do
|
|||
let(:start_at) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
|
||||
let(:end_at) { Time.zone.local(2020, 1, 1, 2, 0, 0) }
|
||||
|
||||
let!(:points) do
|
||||
[
|
||||
# first visit
|
||||
create(:point, :with_known_location, user:, timestamp: start_at),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 5.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 10.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 15.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 20.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 25.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 30.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 35.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 40.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 45.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 50.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 55.minutes),
|
||||
# end of first visit
|
||||
# second visit
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 95.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 100.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 105.minutes)
|
||||
# end of second visit
|
||||
]
|
||||
end
|
||||
let!(:points) { create_visit_points(user, start_at) }
|
||||
|
||||
let(:geocoder_struct) do
|
||||
Struct.new(:data) do
|
||||
|
|
@ -97,12 +75,23 @@ RSpec.describe Visits::Suggest do
|
|||
end
|
||||
|
||||
context 'when reverse geocoding is enabled' do
|
||||
# Use a different time range to avoid interference with main tests
|
||||
let(:reverse_geocoding_start_at) { Time.zone.local(2020, 6, 1, 0, 0, 0) }
|
||||
let(:reverse_geocoding_end_at) { Time.zone.local(2020, 6, 1, 2, 0, 0) }
|
||||
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
|
||||
# Create points for reverse geocoding test in a separate time range
|
||||
create_visit_points(user, reverse_geocoding_start_at)
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
it 'reverse geocodes visits' do
|
||||
expect { subject }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times
|
||||
it 'enqueues reverse geocoding jobs for created visits' do
|
||||
described_class.new(user, start_at: reverse_geocoding_start_at, end_at: reverse_geocoding_end_at).call
|
||||
|
||||
expect(enqueued_jobs.count).to eq(2)
|
||||
expect(enqueued_jobs).to all(have_job_class('ReverseGeocodingJob'))
|
||||
expect(enqueued_jobs).to all(have_arguments_starting_with('place'))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -113,9 +102,51 @@ RSpec.describe Visits::Suggest do
|
|||
|
||||
it 'does not reverse geocode visits' do
|
||||
expect_any_instance_of(Visit).not_to receive(:async_reverse_geocode)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_visit_points(user, start_time)
|
||||
[
|
||||
# first visit
|
||||
create(:point, :with_known_location, user:, timestamp: start_time),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 5.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 10.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 15.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 20.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 25.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 30.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 35.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 40.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 45.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 50.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 55.minutes),
|
||||
# end of first visit
|
||||
|
||||
# second visit
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 95.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 100.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_time + 105.minutes)
|
||||
# end of second visit
|
||||
]
|
||||
end
|
||||
|
||||
def clear_enqueued_jobs
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
end
|
||||
|
||||
def enqueued_jobs
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs
|
||||
end
|
||||
|
||||
def have_job_class(job_class)
|
||||
satisfy { |job| job['job_class'] == job_class }
|
||||
end
|
||||
|
||||
def have_arguments_starting_with(first_argument)
|
||||
satisfy { |job| job['arguments'].first == first_argument }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue