diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3e1078..54d6c096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index 99cb98e8..d1651daa 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 47d40698..cb296a93 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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) diff --git a/app/models/concerns/calculateable.rb b/app/models/concerns/calculateable.rb index 2e890d1e..31e4ff53 100644 --- a/app/models/concerns/calculateable.rb +++ b/app/models/concerns/calculateable.rb @@ -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? diff --git a/app/models/concerns/distance_convertible.rb b/app/models/concerns/distance_convertible.rb new file mode 100644 index 00000000..52054b3d --- /dev/null +++ b/app/models/concerns/distance_convertible.rb @@ -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 diff --git a/app/models/point.rb b/app/models/point.rb index e097a82c..150d653c 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -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 diff --git a/app/models/stat.rb b/app/models/stat.rb index e46a65c5..0fa4e5e5 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -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 diff --git a/app/models/track.rb b/app/models/track.rb index 9bed3e52..9e9724a7 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -2,6 +2,7 @@ class Track < ApplicationRecord include Calculateable + include DistanceConvertible belongs_to :user has_many :points, dependent: :nullify diff --git a/app/models/trip.rb b/app/models/trip.rb index 3178f0b5..7ba14ad5 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -2,6 +2,7 @@ class Trip < ApplicationRecord include Calculateable + include DistanceConvertible has_rich_text :notes diff --git a/app/models/user.rb b/app/models/user.rb index 13f22160..2107c876 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/serializers/stats_serializer.rb b/app/serializers/stats_serializer.rb index 3fd41d47..0fd3cd08 100644 --- a/app/serializers/stats_serializer.rb +++ b/app/serializers/stats_serializer.rb @@ -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 + # Convert from stored meters to kilometers + total_distance_meters = user.stats.sum(:distance) + (total_distance_meters / 1000.0).round(2) 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,23 @@ class StatsSerializer end end + def stats_distance_km(stats) + # Convert from stored meters to kilometers + total_meters = stats.sum(&:distance) + (total_meters / 1000.0).round(2) + 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.0).round(2) end end diff --git a/app/services/tracks/track_builder.rb b/app/services/tracks/track_builder.rb index 343377b1..a68d58ad 100644 --- a/app/services/tracks/track_builder.rb +++ b/app/services/tracks/track_builder.rb @@ -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 @@ -85,27 +85,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 diff --git a/app/views/stats/_stat.html.erb b/app/views/stats/_stat.html.erb index 3b9b4802..f052b2df 100644 --- a/app/views/stats/_stat.html.erb +++ b/app/views/stats/_stat.html.erb @@ -1,31 +1,28 @@ -
-
-
-

- <%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %> - <%= Date::MONTHNAMES[stat.month] %> - <% end %> -

+
+
+

<%= Date::MONTHNAMES[stat.month] %> <%= stat.year %>

-
- Last update <%= human_date(stat.updated_at) %> - <%= link_to '🔄', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %> -
+
+ <%= link_to "Details", points_path(year: stat.year, month: stat.month), + class: "link link-primary" %>
-

<%= number_with_delimiter stat.distance %><%= current_user.safe_settings.distance_unit %>

- <% if DawarichSettings.reverse_geocoding_enabled? %> -
- <%= countries_and_cities_stat_for_month(stat) %> -
- <% end %> - <% if stat.daily_distance %> - <%= column_chart( - stat.daily_distance, - height: '100px', - suffix: " #{current_user.safe_settings.distance_unit}", - xtitle: 'Days', - ytitle: 'Distance' - ) %> - <% end %>
+ +
+
+

<%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %>

+
+
+ +
+ <%= countries_and_cities_stat_for_month(stat) %> +
+ + ", + data-user-settings="<%= current_user.safe_settings.default_settings.to_json %>">
diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index bac6e0bd..ef652ee0 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -82,7 +82,9 @@
<% 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', diff --git a/app/views/trips/_countries.html.erb b/app/views/trips/_countries.html.erb index 01fc652b..69a7fe08 100644 --- a/app/views/trips/_countries.html.erb +++ b/app/views/trips/_countries.html.erb @@ -2,7 +2,7 @@
Distance
-
<%= trip.distance %> <%= distance_unit %>
+
<%= trip.distance_for_user(current_user).round %> <%= distance_unit %>
diff --git a/app/views/trips/_distance.html.erb b/app/views/trips/_distance.html.erb index e6e4d13d..6bb835e6 100644 --- a/app/views/trips/_distance.html.erb +++ b/app/views/trips/_distance.html.erb @@ -1,5 +1,5 @@ <% if trip.distance.present? %> - <%= trip.distance %> <%= distance_unit %> + <%= trip.distance_for_user(current_user).round %> <%= distance_unit %> <% else %> Calculating... diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb index f78e78a0..c65373a1 100644 --- a/app/views/trips/_trip.html.erb +++ b/app/views/trips/_trip.html.erb @@ -5,7 +5,7 @@ <%= trip.name %>

- <%= "#{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}" %>