diff --git a/.app_version b/.app_version
index 9beb74d4..288adf53 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.13.2
+0.13.3
diff --git a/.env.development b/.env.development
index 08a48784..3f83ed07 100644
--- a/.env.development
+++ b/.env.development
@@ -5,3 +5,4 @@ DATABASE_NAME=dawarich_development
DATABASE_PORT=5432
REDIS_URL=redis://localhost:6379/1
PHOTON_API_HOST='photon.komoot.io'
+DISTANCE_UNIT='mi'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f63d60d..089cdf9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,37 @@ 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.13.3] — 2024-09-06
+
+### Added
+
+- Support for miles. To switch to miles, provide `DISTANCE_UNIT` environment variable with value `mi` in the `docker-compose.yml` file. Default value is `km`.
+
+It's recommended to update your stats manually after changing the `DISTANCE_UNIT` environment variable. You can do this by clicking the "Update stats" button on the Stats page.
+
+⚠️IMPORTANT⚠️: All settings are still should be provided in meters. All calculations though will be converted to feets and miles if `DISTANCE_UNIT` is set to `mi`.
+
+```diff
+ dawarich_app:
+ image: freikin/dawarich:latest
+ container_name: dawarich_app
+ environment:
+ APPLICATION_HOST: "localhost"
+ APPLICATION_PROTOCOL: "http"
+ APPLICATION_PORT: "3000"
+ TIME_ZONE: "UTC"
++ DISTANCE_UNIT: "mi"
+ dawarich_sidekiq:
+ image: freikin/dawarich:latest
+ container_name: dawarich_sidekiq
+ environment:
+ APPLICATION_HOST: "localhost"
+ APPLICATION_PROTOCOL: "http"
+ APPLICATION_PORT: "3000"
+ TIME_ZONE: "UTC"
++ DISTANCE_UNIT: "mi"
+```
+
## [0.13.2] — 2024-09-06
### Fixed
diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb
index a792a4ab..f63a6ea8 100644
--- a/app/controllers/map_controller.rb
+++ b/app/controllers/map_controller.rb
@@ -35,7 +35,9 @@ class MapController < ApplicationController
@distance ||= 0
@coordinates.each_cons(2) do
- @distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]], units: :km)
+ @distance += Geocoder::Calculations.distance_between(
+ [_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT.to_sym
+ )
end
@distance.round(1)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index f0531cc0..fd5b86e2 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -50,7 +50,8 @@ module ApplicationHelper
"#{countries} countries, #{cities} cities"
end
- def year_distance_stat_in_km(year, user)
+ def year_distance_stat(year, user)
+ # In km or miles, depending on the application settings (DISTANCE_UNIT)
Stat.year_distance(year, user).sum { _1[1] }
end
@@ -81,7 +82,7 @@ module ApplicationHelper
def sidebar_distance(distance)
return unless distance
- "#{distance} km"
+ "#{distance} #{DISTANCE_UNIT}"
end
def sidebar_points(points)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 1e562441..cb9a17d3 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -25,6 +25,7 @@ export default class extends Controller {
this.userSettings = JSON.parse(this.element.dataset.user_settings);
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
+ this.distanceUnit = this.element.dataset.distance_unit || "km";
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@@ -134,6 +135,13 @@ export default class extends Controller {
createPopupContent(marker) {
const timezone = this.element.dataset.timezone;
+ if (this.distanceUnit === "mi") {
+ // convert marker[5] from km/h to mph
+ marker[5] = marker[5] * 0.621371;
+ // convert marker[3] from meters to feet
+ marker[3] = marker[3] * 3.28084;
+ }
+
return `
Timestamp: ${formatDate(marker[4], timezone)}
Latitude: ${marker[0]}
@@ -276,9 +284,9 @@ export default class extends Controller {
Start: ${firstTimestamp}
End: ${lastTimestamp}
Duration: ${timeOnRoute}
- Total Distance: ${formatDistance(totalDistance)}
+ Total Distance: ${formatDistance(totalDistance, this.distanceUnit)}
`;
-
+console.log(this.distanceUnit);
if (isDebugMode) {
const prevPoint = polylineCoordinates[0];
const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 4a7d6817..7aef91c3 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -1,9 +1,30 @@
// javascript/maps/helpers.js
-export function formatDistance(distance) {
- if (distance < 1000) {
- return `${distance.toFixed(2)} meters`;
+export function formatDistance(distance, unit = 'km') {
+ let smallUnit, bigUnit;
+
+ if (unit === 'mi') {
+ distance *= 0.621371; // Convert km to miles
+ smallUnit = 'ft';
+ bigUnit = 'mi';
+
+ // If the distance is less than 1 mile, return it in feet
+ if (distance < 1) {
+ distance *= 5280; // Convert miles to feet
+ return `${distance.toFixed(2)} ${smallUnit}`;
+ } else {
+ return `${distance.toFixed(2)} ${bigUnit}`;
+ }
} else {
- return `${(distance / 1000).toFixed(2)} km`;
+ smallUnit = 'm';
+ bigUnit = 'km';
+
+ // If the distance is less than 1 km, return it in meters
+ if (distance < 1) {
+ distance *= 1000; // Convert km to meters
+ return `${distance.toFixed(2)} ${smallUnit}`;
+ } else {
+ return `${distance.toFixed(2)} ${bigUnit}`;
+ }
}
}
@@ -37,9 +58,10 @@ export function formatDate(timestamp, timezone) {
return date.toLocaleString("en-GB", { timeZone: timezone });
}
-export function haversineDistance(lat1, lon1, lat2, lon2) {
+export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {
const toRad = (x) => (x * Math.PI) / 180;
- const R = 6371; // Radius of the Earth in kilometers
+ const R_km = 6371; // Radius of the Earth in kilometers
+ const R_miles = 3959; // Radius of the Earth in miles
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
@@ -47,5 +69,10 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
- return R * c * 1000; // Distance in meters
+
+ if (unit === 'miles') {
+ return R_miles * c; // Distance in miles
+ } else {
+ return R_km * c; // Distance in kilometers
+ }
}
diff --git a/app/models/user.rb b/app/models/user.rb
index a7241596..806b3b2f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -35,7 +35,8 @@ class User < ApplicationRecord
.compact
end
- def total_km
+ def total_distance
+ # In km or miles, depending on the application settings (DISTANCE_UNIT)
stats.sum(:distance)
end
diff --git a/app/services/areas/visits/create.rb b/app/services/areas/visits/create.rb
index 5bec87c6..115525bd 100644
--- a/app/services/areas/visits/create.rb
+++ b/app/services/areas/visits/create.rb
@@ -30,10 +30,15 @@ class Areas::Visits::Create
end
def area_points(area)
- area_radius_in_km = area.radius / 1000.0
+ area_radius =
+ if DISTANCE_UNIT.to_sym == :km
+ area.radius / 1000.0
+ else
+ area.radius / 1609.344
+ end
points = Point.where(user_id: user.id)
- .near([area.latitude, area.longitude], area_radius_in_km)
+ .near([area.latitude, area.longitude], area_radius, units: DISTANCE_UNIT.to_sym)
.order(timestamp: :asc)
# check if all points within the area are assigned to a visit
diff --git a/app/services/create_stats.rb b/app/services/create_stats.rb
index 92e903d7..fc086d69 100644
--- a/app/services/create_stats.rb
+++ b/app/services/create_stats.rb
@@ -12,29 +12,12 @@ class CreateStats
def call
users.each do |user|
years.each do |year|
- months.each do |month|
- beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i
- end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i
-
- points = points(user, beginning_of_month_timestamp, end_of_month_timestamp)
- next if points.empty?
-
- stat = Stat.find_or_initialize_by(year:, month:, user:)
- stat.distance = distance(points)
- stat.toponyms = toponyms(points)
- stat.daily_distance = stat.distance_by_day
- stat.save
- end
+ months.each { |month| update_month_stats(user, year, month) }
end
- Notifications::Create.new(user:, kind: :info, title: 'Stats updated', content: 'Stats updated').call
+ create_stats_updated_notification(user)
rescue StandardError => e
- Notifications::Create.new(
- user:,
- kind: :error,
- title: 'Stats update failed',
- content: "#{e.message}, stacktrace: #{e.backtrace.join("\n")}"
- ).call
+ create_stats_update_failed_notification(user, e)
end
end
@@ -49,19 +32,49 @@ class CreateStats
.select(:latitude, :longitude, :timestamp, :city, :country)
end
+ def update_month_stats(user, year, month)
+ beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i
+ end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i
+
+ points = points(user, beginning_of_month_timestamp, end_of_month_timestamp)
+
+ return if points.empty?
+
+ stat = Stat.find_or_initialize_by(year:, month:, user:)
+ stat.distance = distance(points)
+ stat.toponyms = toponyms(points)
+ stat.daily_distance = stat.distance_by_day
+ stat.save
+ end
+
def distance(points)
- km = 0
+ distance = 0
points.each_cons(2) do
- km += Geocoder::Calculations.distance_between(
- [_1.latitude, _1.longitude], [_2.latitude, _2.longitude], units: :km
+ distance += Geocoder::Calculations.distance_between(
+ [_1.latitude, _1.longitude], [_2.latitude, _2.longitude], units: DISTANCE_UNIT.to_sym
)
end
- km
+ distance
end
def toponyms(points)
CountriesAndCities.new(points).call
end
+
+ def create_stats_updated_notification(user)
+ Notifications::Create.new(
+ user:, kind: :info, title: 'Stats updated', content: 'Stats updated'
+ ).call
+ end
+
+ def create_stats_update_failed_notification(user, error)
+ Notifications::Create.new(
+ user:,
+ kind: :error,
+ title: 'Stats update failed',
+ content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
+ ).call
+ end
end
diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb
index 51dfb1a6..efd88945 100644
--- a/app/views/map/index.html.erb
+++ b/app/views/map/index.html.erb
@@ -41,9 +41,10 @@
<%= stat.distance %>km
+<%= stat.distance %><%= DISTANCE_UNIT %>
<% if REVERSE_GEOCODING_ENABLED %>- <% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %> - <%= number_with_delimiter year_distance_stat_in_km(year, current_user) %>km + <% cache [current_user, 'year_distance_stat', year], skip_digest: true do %> + <%= number_with_delimiter year_distance_stat(year, current_user) %><%= DISTANCE_UNIT %> <% end %>
<% if REVERSE_GEOCODING_ENABLED %> @@ -44,7 +44,7 @@ <%= column_chart( Stat.year_distance(year, current_user), height: '200px', - suffix: ' km', + suffix: " #{DISTANCE_UNIT}", xtitle: 'Days', ytitle: 'Distance' ) %> diff --git a/config/initializers/00_constants.rb b/config/initializers/00_constants.rb index 7251d114..198a7472 100644 --- a/config/initializers/00_constants.rb +++ b/config/initializers/00_constants.rb @@ -3,3 +3,4 @@ MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true' PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil) +DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km') diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index 5352049e..7dedd07a 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -2,7 +2,7 @@ settings = { timeout: 5, - units: :km, + units: DISTANCE_UNIT.to_sym, cache: Redis.new, always_raise: :all, cache_options: { diff --git a/docker-compose.yml b/docker-compose.yml index 7c805c76..2f98cee8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,7 @@ services: APPLICATION_HOSTS: localhost TIME_ZONE: Europe/London APPLICATION_PROTOCOL: http + DISTANCE_UNIT: km logging: driver: "json-file" options: @@ -85,6 +86,7 @@ services: APPLICATION_HOSTS: localhost BACKGROUND_PROCESSING_CONCURRENCY: 10 APPLICATION_PROTOCOL: http + DISTANCE_UNIT: km logging: driver: "json-file" options: diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bd7a2c30..25421848 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -50,8 +50,8 @@ RSpec.describe User, type: :model do end end - describe '#total_km' do - subject { user.total_km } + describe '#total_distance' do + subject { user.total_distance } let!(:stat1) { create(:stat, user:, distance: 10) } let!(:stat2) { create(:stat, user:, distance: 20) } diff --git a/spec/services/create_stats_spec.rb b/spec/services/create_stats_spec.rb index 95b9f23f..9bd46728 100644 --- a/spec/services/create_stats_spec.rb +++ b/spec/services/create_stats_spec.rb @@ -21,32 +21,66 @@ RSpec.describe CreateStats do let!(:point2) { create(:point, user:, import:, latitude: 1, longitude: 2) } let!(:point3) { create(:point, user:, import:, latitude: 3, longitude: 4) } - it 'creates stats' do - expect { create_stats }.to change { Stat.count }.by(1) - end - - it 'calculates distance' do - create_stats - - expect(Stat.last.distance).to eq(563) - end - - it 'created notifications' do - expect { create_stats }.to change { Notification.count }.by(1) - end - - context 'when there is an error' do - before do - allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError) + context 'when units are kilometers' do + it 'creates stats' do + expect { create_stats }.to change { Stat.count }.by(1) end - it 'does not create stats' do - expect { create_stats }.not_to(change { Stat.count }) + it 'calculates distance' do + create_stats + + expect(Stat.last.distance).to eq(563) end it 'created notifications' do expect { create_stats }.to change { Notification.count }.by(1) 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 { create_stats }.not_to(change { Stat.count }) + end + + it 'created notifications' do + expect { create_stats }.to change { Notification.count }.by(1) + end + end + end + + context 'when units are miles' do + before { stub_const('DISTANCE_UNIT', 'mi') } + + it 'creates stats' do + expect { create_stats }.to change { Stat.count }.by(1) + end + + it 'calculates distance' do + create_stats + + expect(Stat.last.distance).to eq(349) + end + + it 'created notifications' do + expect { create_stats }.to change { Notification.count }.by(1) + 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 { create_stats }.not_to(change { Stat.count }) + end + + it 'created notifications' do + expect { create_stats }.to change { Notification.count }.by(1) + end + end end end end