mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
commit
cb71a33623
19 changed files with 197 additions and 70 deletions
|
|
@ -1 +1 @@
|
|||
0.13.2
|
||||
0.13.3
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
31
CHANGELOG.md
31
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<b>Timestamp:</b> ${formatDate(marker[4], timezone)}<br>
|
||||
<b>Latitude:</b> ${marker[0]}<br>
|
||||
|
|
@ -276,9 +284,9 @@ export default class extends Controller {
|
|||
<b>Start:</b> ${firstTimestamp}<br>
|
||||
<b>End:</b> ${lastTimestamp}<br>
|
||||
<b>Duration:</b> ${timeOnRoute}<br>
|
||||
<b>Total Distance:</b> ${formatDistance(totalDistance)}<br>
|
||||
<b>Total Distance:</b> ${formatDistance(totalDistance, this.distanceUnit)}<br>
|
||||
`;
|
||||
|
||||
console.log(this.distanceUnit);
|
||||
if (isDebugMode) {
|
||||
const prevPoint = polylineCoordinates[0];
|
||||
const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@
|
|||
|
||||
<div
|
||||
class="w-full"
|
||||
data-controller="maps"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-user_settings=<%= current_user.settings.to_json %>
|
||||
data-controller="maps"
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-maps-target="container" class="h-[25rem] w-auto min-h-screen">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<%= "#{Date::MONTHNAMES[stat.month]} of #{stat.year}" %>
|
||||
<% end %>
|
||||
</h2>
|
||||
<p><%= stat.distance %>km</p>
|
||||
<p><%= stat.distance %><%= DISTANCE_UNIT %></p>
|
||||
<% if REVERSE_GEOCODING_ENABLED %>
|
||||
<div class="card-actions justify-end">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<%= column_chart(
|
||||
stat.daily_distance,
|
||||
height: '100px',
|
||||
suffix: ' km',
|
||||
suffix: " #{DISTANCE_UNIT}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<%= column_chart(
|
||||
Stat.year_distance(year, current_user),
|
||||
height: '200px',
|
||||
suffix: ' km',
|
||||
suffix: " #{DISTANCE_UNIT}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-primary">
|
||||
<%= number_with_delimiter(current_user.total_km) %> km
|
||||
<%= number_with_delimiter(current_user.total_distance) %> <%= DISTANCE_UNIT %>
|
||||
</div>
|
||||
<div class="stat-title">Total distance</div>
|
||||
</div>
|
||||
|
|
@ -32,8 +32,8 @@
|
|||
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
|
||||
</h2>
|
||||
<p>
|
||||
<% 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 %>
|
||||
</p>
|
||||
<% 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'
|
||||
) %>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
settings = {
|
||||
timeout: 5,
|
||||
units: :km,
|
||||
units: DISTANCE_UNIT.to_sym,
|
||||
cache: Redis.new,
|
||||
always_raise: :all,
|
||||
cache_options: {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue