From 0b362168c98aca2f1bd6265fb0cffad6fdd476c8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 10 Feb 2025 20:37:20 +0100 Subject: [PATCH 1/8] Implement custom map tiles and user settings with default values --- CHANGELOG.md | 15 ++- app/controllers/settings/maps_controller.rb | 22 +++++ .../controllers/map_preview_controller.js | 67 ++++++++++++++ app/javascript/controllers/maps_controller.js | 30 +++++- app/models/user.rb | 10 +- app/services/areas/visits/create.rb | 4 +- app/services/immich/request_photos.rb | 6 +- app/services/photoprism/request_photos.rb | 6 +- app/services/photos/thumbnail.rb | 14 ++- app/services/users/safe_settings.rb | 91 +++++++++++++++++++ app/views/imports/index.html.erb | 4 +- app/views/map/index.html.erb | 2 +- app/views/settings/_navigation.html.erb | 1 + app/views/settings/index.html.erb | 8 +- app/views/settings/maps/index.html.erb | 45 +++++++++ config/routes.rb | 2 + spec/requests/settings/maps_spec.rb | 43 +++++++++ spec/services/photos/thumbnail_spec.rb | 2 +- 18 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 app/controllers/settings/maps_controller.rb create mode 100644 app/javascript/controllers/map_preview_controller.js create mode 100644 app/services/users/safe_settings.rb create mode 100644 app/views/settings/maps/index.html.erb create mode 100644 spec/requests/settings/maps_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 372d12b5..30ba22d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,20 @@ 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.24.0 - 2025-02-09 +# 0.24.1 - 2025-02-10 + +## Custom map tiles + +In the user settings, you can now set a custom tile URL for the map. This is useful if you want to use a custom map tile provider or if you want to use a map tile provider that is not listed in the dropdown. + +To set a custom tile URL, go to the user settings and set the `Maps` section to your liking. Be mindful that currently, only raster tiles are supported. The URL should be a valid tile URL, like `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`. You, as the user, are responsible for any extra costs that may occur due to using a custom tile URL. + +### Added + +- Safe settings for user with default values. +- In the user settings, you can now set a custom tile URL for the map. #429 #715 + +# 0.24.0 - 2025-02-10 ## Points speed units diff --git a/app/controllers/settings/maps_controller.rb b/app/controllers/settings/maps_controller.rb new file mode 100644 index 00000000..59beb04d --- /dev/null +++ b/app/controllers/settings/maps_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Settings::MapsController < ApplicationController + before_action :authenticate_user! + + def index + @maps = current_user.safe_settings.maps + end + + def update + current_user.settings['maps'] = settings_params + current_user.save! + + redirect_to settings_maps_path, notice: 'Settings updated' + end + + private + + def settings_params + params.require(:maps).permit(:name, :url) + end +end diff --git a/app/javascript/controllers/map_preview_controller.js b/app/javascript/controllers/map_preview_controller.js new file mode 100644 index 00000000..3b610a33 --- /dev/null +++ b/app/javascript/controllers/map_preview_controller.js @@ -0,0 +1,67 @@ +import { Controller } from "@hotwired/stimulus" +import L from "leaflet" +import { showFlashMessage } from "../maps/helpers" + +export default class extends Controller { + static targets = ["urlInput", "mapContainer", "saveButton"] + + DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + + connect() { + console.log("Controller connected!") + // Wait for the next frame to ensure the DOM is ready + requestAnimationFrame(() => { + // Force container height + this.mapContainerTarget.style.height = '500px' + this.initializeMap() + }) + } + + initializeMap() { + console.log("Initializing map...") + if (!this.map) { + this.map = L.map(this.mapContainerTarget).setView([51.505, -0.09], 13) + // Invalidate size after initialization + setTimeout(() => { + this.map.invalidateSize() + }, 0) + this.updatePreview() + } + } + + updatePreview() { + console.log("Updating preview...") + const url = this.urlInputTarget.value || this.DEFAULT_TILE_URL + + // Only animate if save button target exists + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.classList.add('btn-animate') + setTimeout(() => { + this.saveButtonTarget.classList.remove('btn-animate') + }, 1000) + } + + if (this.currentLayer) { + this.map.removeLayer(this.currentLayer) + } + + try { + this.currentLayer = L.tileLayer(url, { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + } catch (e) { + console.error('Invalid tile URL:', e) + showFlashMessage('error', 'Invalid tile URL. Reverting to OpenStreetMap.') + + // Reset input to default OSM URL + this.urlInputTarget.value = this.DEFAULT_TILE_URL + + // Create default layer + this.currentLayer = L.tileLayer(this.DEFAULT_TILE_URL, { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + } + } +} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 2e921c86..f08af801 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -385,8 +385,7 @@ export default class extends Controller { baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; - - return { + let maps = { OpenStreetMap: osmMapLayer(this.map, selectedLayerName), "OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName), OPNV: OPNVMapLayer(this.map, selectedLayerName), @@ -397,6 +396,33 @@ export default class extends Controller { esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName), esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName) }; + + // Add custom map if it exists in settings + if (this.userSettings.maps && this.userSettings.maps.url) { + const customLayer = L.tileLayer(this.userSettings.maps.url, { + maxZoom: 19, + attribution: "© OpenStreetMap contributors" + }); + + // If this is the preferred layer, add it to the map immediately + if (selectedLayerName === this.userSettings.maps.name) { + customLayer.addTo(this.map); + // Remove any other base layers that might be active + Object.values(maps).forEach(layer => { + if (this.map.hasLayer(layer)) { + this.map.removeLayer(layer); + } + }); + } + + maps[this.userSettings.maps.name] = customLayer; + } else { + // If no custom map is set, ensure a default layer is added + const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"]; + defaultLayer.addTo(this.map); + } + + return maps; } removeEventListeners() { diff --git a/app/models/user.rb b/app/models/user.rb index b8d27f17..b2ffd980 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,13 +16,18 @@ class User < ApplicationRecord has_many :trips, dependent: :destroy after_create :create_api_key - before_save :strip_trailing_slashes + before_save :sanitize_input validates :email, presence: true + validates :reset_password_token, uniqueness: true, allow_nil: true attribute :admin, :boolean, default: false + def safe_settings + Users::SafeSettings.new(settings) + end + def countries_visited stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact end @@ -99,8 +104,9 @@ class User < ApplicationRecord save end - def strip_trailing_slashes + def sanitize_input settings['immich_url']&.gsub!(%r{/+\z}, '') settings['photoprism_url']&.gsub!(%r{/+\z}, '') + settings['maps']['url']&.strip! end end diff --git a/app/services/areas/visits/create.rb b/app/services/areas/visits/create.rb index f58bf4b7..768f5f9f 100644 --- a/app/services/areas/visits/create.rb +++ b/app/services/areas/visits/create.rb @@ -6,8 +6,8 @@ class Areas::Visits::Create def initialize(user, areas) @user = user @areas = areas - @time_threshold_minutes = 30 || user.settings['time_threshold_minutes'] - @merge_threshold_minutes = 15 || user.settings['merge_threshold_minutes'] + @time_threshold_minutes = 30 || user.safe_settings.time_threshold_minutes + @merge_threshold_minutes = 15 || user.safe_settings.merge_threshold_minutes end def call diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 0d3f6e1f..0dfcbcd5 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -5,15 +5,15 @@ class Immich::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata") - @immich_api_key = user.settings['immich_api_key'] + @immich_api_base_url = URI.parse("#{user.safe_settings.immich_url}/api/search/metadata") + @immich_api_key = user.safe_settings.immich_api_key @start_date = start_date @end_date = end_date end def call raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? - raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? + raise ArgumentError, 'Immich URL is missing' if user.safe_settings.immich_url.blank? data = retrieve_immich_data diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 276e7e5c..0f7fd93b 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -9,14 +9,14 @@ class Photoprism::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos") - @photoprism_api_key = user.settings['photoprism_api_key'] + @photoprism_api_base_url = URI.parse("#{user.safe_settings.photoprism_url}/api/v1/photos") + @photoprism_api_key = user.safe_settings.photoprism_api_key @start_date = start_date @end_date = end_date end def call - raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank? + raise ArgumentError, 'Photoprism URL is missing' if user.safe_settings.photoprism_url.blank? raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank? data = retrieve_photoprism_data diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb index 6bdb7fd5..4f927aad 100644 --- a/app/services/photos/thumbnail.rb +++ b/app/services/photos/thumbnail.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Photos::Thumbnail + SUPPORTED_SOURCES = %w[immich photoprism].freeze + def initialize(user, source, id) @user = user @source = source @@ -8,6 +10,8 @@ class Photos::Thumbnail end def call + raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source) + HTTParty.get(request_url, headers: headers) end @@ -16,11 +20,11 @@ class Photos::Thumbnail attr_reader :user, :source, :id def source_url - user.settings["#{source}_url"] + user.safe_settings.public_send("#{source}_url") end def source_api_key - user.settings["#{source}_api_key"] + user.safe_settings.public_send("#{source}_api_key") end def source_path @@ -30,8 +34,6 @@ class Photos::Thumbnail when 'photoprism' preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}") "/api/v1/t/#{id}/#{preview_token}/tile_500" - else - raise "Unsupported source: #{source}" end end @@ -48,4 +50,8 @@ class Photos::Thumbnail request_headers end + + def unsupported_source_error + raise ArgumentError, "Unsupported source: #{source}" + end end diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb new file mode 100644 index 00000000..64d41b12 --- /dev/null +++ b/app/services/users/safe_settings.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class Users::SafeSettings + attr_reader :settings + + def initialize(settings) + @settings = settings + end + + # rubocop:disable Metrics/MethodLength + def config + { + fog_of_war_meters: fog_of_war_meters, + meters_between_routes: meters_between_routes, + preferred_map_layer: preferred_map_layer, + speed_colored_routes: speed_colored_routes, + points_rendering_mode: points_rendering_mode, + minutes_between_routes: minutes_between_routes, + time_threshold_minutes: time_threshold_minutes, + merge_threshold_minutes: merge_threshold_minutes, + live_map_enabled: live_map_enabled, + route_opacity: route_opacity, + immich_url: immich_url, + immich_api_key: immich_api_key, + photoprism_url: photoprism_url, + photoprism_api_key: photoprism_api_key, + maps: maps + } + end + # rubocop:enable Metrics/MethodLength + + def fog_of_war_meters + settings['fog_of_war_meters'] || 50 + end + + def meters_between_routes + settings['meters_between_routes'] || 500 + end + + def preferred_map_layer + settings['preferred_map_layer'] || 'OpenStreetMap' + end + + def speed_colored_routes + settings['speed_colored_routes'] || false + end + + def points_rendering_mode + settings['points_rendering_mode'] || 'raw' + end + + def minutes_between_routes + settings['minutes_between_routes'] || 30 + end + + def time_threshold_minutes + settings['time_threshold_minutes'] || 30 + end + + def merge_threshold_minutes + settings['merge_threshold_minutes'] || 15 + end + + def live_map_enabled + settings['live_map_enabled'] || true + end + + def route_opacity + settings['route_opacity'] || 0.6 + end + + def immich_url + settings['immich_url'] + end + + def immich_api_key + settings['immich_api_key'] + end + + def photoprism_url + settings['photoprism_url'] + end + + def photoprism_api_key + settings['photoprism_api_key'] + end + + def maps + settings['maps'] || {} + end +end diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index d2ee8d30..97f82a38 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -5,12 +5,12 @@

Imports

<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> - <% if current_user.settings['immich_url'] && current_user.settings['immich_api_key'] %> + <% if current_user.safe_settings.immich_url && current_user.safe_settings.immich_api_key %> <%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> <% else %> Import Immich data <% end %> - <% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %> + <% if current_user.safe_settings.photoprism_url && current_user.safe_settings.photoprism_api_key %> <%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> <% else %> Import Photoprism data diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index d3c39f80..9fa4a0fe 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -49,7 +49,7 @@ data-points-target="map" data-distance_unit="<%= DISTANCE_UNIT %>" data-api_key="<%= current_user.api_key %>" - data-user_settings=<%= current_user.settings.to_json %> + data-user_settings='<%= current_user.settings.to_json.html_safe %>' data-coordinates="<%= @coordinates %>" data-distance="<%= @distance %>" data-points_number="<%= @points_number %>" diff --git a/app/views/settings/_navigation.html.erb b/app/views/settings/_navigation.html.erb index b0b20437..3f5aa627 100644 --- a/app/views/settings/_navigation.html.erb +++ b/app/views/settings/_navigation.html.erb @@ -4,4 +4,5 @@ <%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %> <%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %> <% end %> + <%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %> diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 613cfe73..fc828c07 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -9,20 +9,20 @@ <%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<%= f.label :immich_url %> - <%= f.text_field :immich_url, value: current_user.settings['immich_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %> + <%= f.text_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
<%= f.label :immich_api_key %> - <%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %> + <%= f.text_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
<%= f.label :photoprism_url %> - <%= f.text_field :photoprism_url, value: current_user.settings['photoprism_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %> + <%= f.text_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
<%= f.label :photoprism_api_key %> - <%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %> + <%= f.text_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
diff --git a/app/views/settings/maps/index.html.erb b/app/views/settings/maps/index.html.erb new file mode 100644 index 00000000..b295028c --- /dev/null +++ b/app/views/settings/maps/index.html.erb @@ -0,0 +1,45 @@ +<% content_for :title, "Background jobs" %> + +
+ <%= render 'settings/navigation' %> + +
+

Maps settings

+
+ +
+ <%= form_for :maps, + url: settings_maps_path, + method: :patch, + autocomplete: "off", + data: { turbo_method: :patch, turbo: false }, + class: "lg:col-span-1" do |f| %> +
+ <%= f.label :name %> + <%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered" %> +
+ +
+ <%= f.label :url, 'URL' %> + <%= f.text_field :url, + value: @maps['url'], + autocomplete: "off", + placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + class: "input input-bordered", + data: { + map_preview_target: "urlInput", + action: "input->map-preview#updatePreview" + } %> +
+ + <%= f.submit 'Save', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %> + <% end %> + +
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index 9a6a38ca..c3ef1717 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,8 @@ Rails.application.routes.draw do namespace :settings do resources :background_jobs, only: %i[index create destroy] resources :users, only: %i[index create destroy edit update] + resources :maps, only: %i[index] + patch 'maps', to: 'maps#update' end patch 'settings', to: 'settings#update' diff --git a/spec/requests/settings/maps_spec.rb b/spec/requests/settings/maps_spec.rb new file mode 100644 index 00000000..4e641945 --- /dev/null +++ b/spec/requests/settings/maps_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'settings/maps', type: :request do + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + context 'when user is authenticated' do + let!(:user) { create(:user) } + + before do + sign_in user + end + + describe 'GET /index' do + it 'returns a success response' do + get settings_maps_url + + expect(response).to be_successful + end + end + + describe 'PATCH /update' do + it 'returns a success response' do + patch settings_maps_path, params: { maps: { name: 'Test', url: 'https://test.com' } } + + expect(response).to redirect_to(settings_maps_path) + expect(user.settings['maps']).to eq({ 'name' => 'Test', 'url' => 'https://test.com' }) + end + end + end + + context 'when user is not authenticated' do + it 'redirects to the sign in page' do + get settings_maps_path + + expect(response).to redirect_to(new_user_session_path) + end + end +end diff --git a/spec/services/photos/thumbnail_spec.rb b/spec/services/photos/thumbnail_spec.rb index c687e370..00c64a07 100644 --- a/spec/services/photos/thumbnail_spec.rb +++ b/spec/services/photos/thumbnail_spec.rb @@ -70,7 +70,7 @@ RSpec.describe Photos::Thumbnail do let(:source) { 'unsupported' } it 'raises an error' do - expect { subject }.to raise_error(RuntimeError, 'Unsupported source: unsupported') + expect { subject }.to raise_error(ArgumentError, 'Unsupported source: unsupported') end end end From d2d6f95322af0ce99b4c3709587efca250f2a97a Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 10 Feb 2025 20:48:16 +0100 Subject: [PATCH 2/8] Fix accessing nested settings --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index b2ffd980..97fb9fe0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -107,6 +107,6 @@ class User < ApplicationRecord def sanitize_input settings['immich_url']&.gsub!(%r{/+\z}, '') settings['photoprism_url']&.gsub!(%r{/+\z}, '') - settings['maps']['url']&.strip! + settings.try(:[], 'maps')&.try(:[], 'url')&.strip! end end From 1580fb8ade978a582ccb2a7204cf5e3a54e07251 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 20:45:36 +0100 Subject: [PATCH 3/8] Export map tiles usage to Prometheus --- CHANGELOG.md | 7 ++ Procfile.prometheus.dev | 2 +- README.md | 1 + .../api/v1/tile_usages_controller.rb | 9 +++ app/javascript/controllers/maps_controller.js | 26 ++++++- app/javascript/maps/tile_monitor.js | 67 +++++++++++++++++++ app/services/tile_usage/track.rb | 19 ++++++ app/views/map/index.html.erb | 1 + app/views/settings/maps/index.html.erb | 17 ++++- config/initializers/03_dawarich_settings.rb | 11 ++- config/initializers/prometheus.rb | 2 +- config/routes.rb | 2 + 12 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 app/controllers/api/v1/tile_usages_controller.rb create mode 100644 app/javascript/maps/tile_monitor.js create mode 100644 app/services/tile_usage/track.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ba22d1..269617f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ To set a custom tile URL, go to the user settings and set the `Maps` section to - Safe settings for user with default values. - In the user settings, you can now set a custom tile URL for the map. #429 #715 +- If you have Prometheus exporter enabled, you can now see a `ruby_dawarich_map_tiles` metric in Prometheus, which shows the total number of map tiles loaded. Example: + +``` +# HELP ruby_dawarich_map_tiles +# TYPE ruby_dawarich_map_tiles gauge +ruby_dawarich_map_tiles 99 +``` # 0.24.0 - 2025-02-10 diff --git a/Procfile.prometheus.dev b/Procfile.prometheus.dev index 71fe0374..95a12639 100644 --- a/Procfile.prometheus.dev +++ b/Procfile.prometheus.dev @@ -1,2 +1,2 @@ prometheus_exporter: bundle exec prometheus_exporter -b ANY -web: bin/rails server -p 3000 -b :: \ No newline at end of file +web: bin/rails server -p 3000 -b :: diff --git a/README.md b/README.md index 0d21ed03..5aa76a1b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Donate using crypto: [0x6bAd13667692632f1bF926cA9B421bEe7EaEB8D4](https://ethers - Explore statistics like the number of countries and cities visited, total distance traveled, and more! 📄 **Changelog**: Find the latest updates [here](CHANGELOG.md). + 👩‍💻 **Contribute**: See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute to Dawarich. --- diff --git a/app/controllers/api/v1/tile_usages_controller.rb b/app/controllers/api/v1/tile_usages_controller.rb new file mode 100644 index 00000000..d5d74d9d --- /dev/null +++ b/app/controllers/api/v1/tile_usages_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Api::V1::TileUsagesController < ApiController + def create + TileUsage::Track.new(params[:tile_count].to_i).call + + head :ok + end +end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index f08af801..53b39c20 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -8,9 +8,7 @@ import { createMarkersArray } from "../maps/markers"; import { createPolylinesLayer, updatePolylinesOpacity, - updatePolylinesColors, - calculateSpeed, - getSpeedColor + updatePolylinesColors } from "../maps/polylines"; import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; @@ -32,9 +30,13 @@ import { countryCodesMap } from "../maps/country_codes"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; +import { TileMonitor } from "../maps/tile_monitor"; export default class extends Controller { static targets = ["container"]; + static values = { + monitoringEnabled: Boolean + } settingsButtonAdded = false; layerControl = null; @@ -245,6 +247,19 @@ export default class extends Controller { if (this.liveMapEnabled) { this.setupSubscription(); } + + // Initialize tile monitor + this.tileMonitor = new TileMonitor(this.monitoringEnabledValue, this.apiKey); + + // Add tile load event handlers to each base layer + Object.entries(this.baseMaps()).forEach(([name, layer]) => { + layer.on('tileload', () => { + this.tileMonitor.recordTileLoad(name); + }); + }); + + // Start monitoring + this.tileMonitor.startMonitoring(); } disconnect() { @@ -260,6 +275,11 @@ export default class extends Controller { if (this.map) { this.map.remove(); } + + // Stop tile monitoring + if (this.tileMonitor) { + this.tileMonitor.stopMonitoring(); + } } setupSubscription() { diff --git a/app/javascript/maps/tile_monitor.js b/app/javascript/maps/tile_monitor.js new file mode 100644 index 00000000..bd5da516 --- /dev/null +++ b/app/javascript/maps/tile_monitor.js @@ -0,0 +1,67 @@ +export class TileMonitor { + constructor(monitoringEnabled, apiKey) { + this.monitoringEnabled = monitoringEnabled; + this.apiKey = apiKey; + this.tileQueue = 0; + this.tileUpdateInterval = null; + } + + startMonitoring() { + // Only start the interval if monitoring is enabled + if (!this.monitoringEnabled) return; + + // Clear any existing interval + if (this.tileUpdateInterval) { + clearInterval(this.tileUpdateInterval); + } + + // Set up a regular interval to send stats + this.tileUpdateInterval = setInterval(() => { + this.sendTileUsage(); + }, 5000); // Exactly every 5 seconds + } + + stopMonitoring() { + if (this.tileUpdateInterval) { + clearInterval(this.tileUpdateInterval); + this.sendTileUsage(); // Send any remaining stats + } + } + + recordTileLoad() { + if (!this.monitoringEnabled) return; + this.tileQueue += 1; + } + + sendTileUsage() { + // Don't send if monitoring is disabled or queue is empty + if (!this.monitoringEnabled || this.tileQueue === 0) return; + + const currentCount = this.tileQueue; + console.log('Sending tile usage batch:', currentCount); + + fetch('/api/v1/tile_usages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + tile_count: currentCount + }) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // Only subtract sent count if it hasn't changed + if (this.tileQueue === currentCount) { + this.tileQueue = 0; + } else { + this.tileQueue -= currentCount; + } + console.log('Tile usage batch sent successfully'); + }) + .catch(error => console.error('Error recording tile usage:', error)); + } +} diff --git a/app/services/tile_usage/track.rb b/app/services/tile_usage/track.rb new file mode 100644 index 00000000..69d1b361 --- /dev/null +++ b/app/services/tile_usage/track.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class TileUsage::Track + def initialize(count = 1) + @count = count + end + + def call + metric_data = { + type: 'counter', + name: 'dawarich_map_tiles', + value: @count + } + + PrometheusExporter::Client.default.send_json(metric_data) + rescue StandardError => e + Rails.logger.error("Failed to send tile usage metric: #{e.message}") + end +end diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 9fa4a0fe..511f12a7 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -53,6 +53,7 @@ data-coordinates="<%= @coordinates %>" data-distance="<%= @distance %>" data-points_number="<%= @points_number %>" + data-maps-monitoring-enabled-value="<%= DawarichSettings.prometheus_exporter_enabled? %>" data-timezone="<%= Rails.configuration.time_zone %>">
diff --git a/app/views/settings/maps/index.html.erb b/app/views/settings/maps/index.html.erb index b295028c..2e50950d 100644 --- a/app/views/settings/maps/index.html.erb +++ b/app/views/settings/maps/index.html.erb @@ -3,10 +3,25 @@
<%= render 'settings/navigation' %> -
+

Maps settings

+ +
<%= form_for :maps, url: settings_maps_path, diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb index 451ed716..589d9ef1 100644 --- a/config/initializers/03_dawarich_settings.rb +++ b/config/initializers/03_dawarich_settings.rb @@ -18,12 +18,11 @@ class DawarichSettings @geoapify_enabled ||= GEOAPIFY_API_KEY.present? end - def meters_between_tracks - @meters_between_tracks ||= 300 - end - - def minutes_between_tracks - @minutes_between_tracks ||= 20 + def prometheus_exporter_enabled? + @prometheus_exporter_enabled ||= + ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' && + ENV['PROMETHEUS_EXPORTER_HOST'].present? && + ENV['PROMETHEUS_EXPORTER_PORT'].present? end end end diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index 3573fb84..1a2f38e0 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -if !Rails.env.test? && ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' +if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled? require 'prometheus_exporter/middleware' require 'prometheus_exporter/instrumentation' diff --git a/config/routes.rb b/config/routes.rb index c3ef1717..45b55576 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,8 @@ Rails.application.routes.draw do get 'thumbnail', constraints: { id: %r{[^/]+} } end end + + resources :tile_usages, only: [:create] end end end From b2e6a141fc347828c3dd6f819e0d56e83224b5a0 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 21:04:12 +0100 Subject: [PATCH 4/8] Rehash paths and add tests --- .../api/v1/maps/tile_usage_controller.rb | 15 ++++++++ .../api/v1/tile_usages_controller.rb | 9 ----- app/javascript/maps/tile_monitor.js | 6 ++- app/services/{ => maps}/tile_usage/track.rb | 2 +- config/routes.rb | 4 +- spec/requests/api/v1/maps/tile_usage_spec.rb | 37 +++++++++++++++++++ spec/services/maps/tile_usage/track_spec.rb | 29 +++++++++++++++ 7 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 app/controllers/api/v1/maps/tile_usage_controller.rb delete mode 100644 app/controllers/api/v1/tile_usages_controller.rb rename app/services/{ => maps}/tile_usage/track.rb (92%) create mode 100644 spec/requests/api/v1/maps/tile_usage_spec.rb create mode 100644 spec/services/maps/tile_usage/track_spec.rb diff --git a/app/controllers/api/v1/maps/tile_usage_controller.rb b/app/controllers/api/v1/maps/tile_usage_controller.rb new file mode 100644 index 00000000..43f8f070 --- /dev/null +++ b/app/controllers/api/v1/maps/tile_usage_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::V1::Maps::TileUsageController < ApiController + def create + Maps::TileUsage::Track.new(tile_usage_params[:count].to_i).call + + head :ok + end + + private + + def tile_usage_params + params.require(:tile_usage).permit(:count) + end +end diff --git a/app/controllers/api/v1/tile_usages_controller.rb b/app/controllers/api/v1/tile_usages_controller.rb deleted file mode 100644 index d5d74d9d..00000000 --- a/app/controllers/api/v1/tile_usages_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::TileUsagesController < ApiController - def create - TileUsage::Track.new(params[:tile_count].to_i).call - - head :ok - end -end diff --git a/app/javascript/maps/tile_monitor.js b/app/javascript/maps/tile_monitor.js index bd5da516..3a4ff36e 100644 --- a/app/javascript/maps/tile_monitor.js +++ b/app/javascript/maps/tile_monitor.js @@ -40,14 +40,16 @@ export class TileMonitor { const currentCount = this.tileQueue; console.log('Sending tile usage batch:', currentCount); - fetch('/api/v1/tile_usages', { + fetch('/api/v1/maps/tile_usage', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ - tile_count: currentCount + tile_usage: { + count: currentCount + } }) }) .then(response => { diff --git a/app/services/tile_usage/track.rb b/app/services/maps/tile_usage/track.rb similarity index 92% rename from app/services/tile_usage/track.rb rename to app/services/maps/tile_usage/track.rb index 69d1b361..13fd1711 100644 --- a/app/services/tile_usage/track.rb +++ b/app/services/maps/tile_usage/track.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TileUsage::Track +class Maps::TileUsage::Track def initialize(count = 1) @count = count end diff --git a/config/routes.rb b/config/routes.rb index 45b55576..09ca7fbc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -97,7 +97,9 @@ Rails.application.routes.draw do end end - resources :tile_usages, only: [:create] + namespace :maps do + resources :tile_usage, only: [:create] + end end end end diff --git a/spec/requests/api/v1/maps/tile_usage_spec.rb b/spec/requests/api/v1/maps/tile_usage_spec.rb new file mode 100644 index 00000000..8c35d9a6 --- /dev/null +++ b/spec/requests/api/v1/maps/tile_usage_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Maps::TileUsage', type: :request do + describe 'POST /api/v1/maps/tile_usage' do + let(:tile_count) { 5 } + let(:track_service) { instance_double(Maps::TileUsage::Track) } + + before do + allow(Maps::TileUsage::Track).to receive(:new).with(tile_count).and_return(track_service) + allow(track_service).to receive(:call) + end + + context 'when user is authenticated' do + let(:user) { create(:user) } + + it 'tracks tile usage' do + post '/api/v1/maps/tile_usage', + params: { tile_usage: { count: tile_count } }, + headers: { 'Authorization' => "Bearer #{user.api_key}" } + + expect(Maps::TileUsage::Track).to have_received(:new).with(tile_count) + expect(track_service).to have_received(:call) + expect(response).to have_http_status(:ok) + end + end + + context 'when user is not authenticated' do + it 'returns unauthorized' do + post '/api/v1/maps/tile_usage', params: { tile_usage: { count: tile_count } } + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/services/maps/tile_usage/track_spec.rb b/spec/services/maps/tile_usage/track_spec.rb new file mode 100644 index 00000000..82f0360c --- /dev/null +++ b/spec/services/maps/tile_usage/track_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'prometheus_exporter/client' + +RSpec.describe Maps::TileUsage::Track do + describe '#call' do + subject(:track) { described_class.new(tile_count).call } + + let(:tile_count) { 5 } + let(:prometheus_client) { instance_double(PrometheusExporter::Client) } + + before do + allow(PrometheusExporter::Client).to receive(:default).and_return(prometheus_client) + end + + it 'tracks tile usage' do + expect(prometheus_client).to receive(:send_json).with( + { + type: 'counter', + name: 'dawarich_map_tiles', + value: tile_count + } + ) + + track + end + end +end From 06da91df27d2c124038f57de14e5b9d73d03dcb1 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 21:12:35 +0100 Subject: [PATCH 5/8] Fix live map enabled default value --- app/services/users/safe_settings.rb | 4 +- spec/services/users/safe_settings_spec.rb | 143 ++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 spec/services/users/safe_settings_spec.rb diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb index 64d41b12..dab0a516 100644 --- a/app/services/users/safe_settings.rb +++ b/app/services/users/safe_settings.rb @@ -62,7 +62,9 @@ class Users::SafeSettings end def live_map_enabled - settings['live_map_enabled'] || true + return settings['live_map_enabled'] if settings.key?('live_map_enabled') + + true end def route_opacity diff --git a/spec/services/users/safe_settings_spec.rb b/spec/services/users/safe_settings_spec.rb new file mode 100644 index 00000000..aaafbac9 --- /dev/null +++ b/spec/services/users/safe_settings_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +RSpec.describe Users::SafeSettings do + describe '#config' do + context 'with default values' do + let(:settings) { {} } + let(:safe_settings) { described_class.new(settings) } + + it 'returns default configuration' do + expect(safe_settings.config).to eq( + { + fog_of_war_meters: 50, + meters_between_routes: 500, + preferred_map_layer: 'OpenStreetMap', + speed_colored_routes: false, + points_rendering_mode: 'raw', + minutes_between_routes: 30, + time_threshold_minutes: 30, + merge_threshold_minutes: 15, + live_map_enabled: true, + route_opacity: 0.6, + immich_url: nil, + immich_api_key: nil, + photoprism_url: nil, + photoprism_api_key: nil, + maps: {} + } + ) + end + end + + context 'with custom values' do + let(:settings) do + { + 'fog_of_war_meters' => 100, + 'meters_between_routes' => 1000, + 'preferred_map_layer' => 'Satellite', + 'speed_colored_routes' => true, + 'points_rendering_mode' => 'simplified', + 'minutes_between_routes' => 60, + 'time_threshold_minutes' => 45, + 'merge_threshold_minutes' => 20, + 'live_map_enabled' => false, + 'route_opacity' => 0.8, + 'immich_url' => 'https://immich.example.com', + 'immich_api_key' => 'immich-key', + 'photoprism_url' => 'https://photoprism.example.com', + 'photoprism_api_key' => 'photoprism-key', + 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' } + } + end + let(:safe_settings) { described_class.new(settings) } + + it 'returns custom configuration' do + expect(safe_settings.config).to eq( + { + fog_of_war_meters: 100, + meters_between_routes: 1000, + preferred_map_layer: 'Satellite', + speed_colored_routes: true, + points_rendering_mode: 'simplified', + minutes_between_routes: 60, + time_threshold_minutes: 45, + merge_threshold_minutes: 20, + live_map_enabled: false, + route_opacity: 0.8, + immich_url: 'https://immich.example.com', + immich_api_key: 'immich-key', + photoprism_url: 'https://photoprism.example.com', + photoprism_api_key: 'photoprism-key', + maps: { 'name' => 'custom', 'url' => 'https://custom.example.com' } + } + ) + end + end + end + + describe 'individual settings' do + let(:safe_settings) { described_class.new(settings) } + + context 'with default values' do + let(:settings) { {} } + + it 'returns default values for each setting' do + expect(safe_settings.fog_of_war_meters).to eq(50) + expect(safe_settings.meters_between_routes).to eq(500) + expect(safe_settings.preferred_map_layer).to eq('OpenStreetMap') + expect(safe_settings.speed_colored_routes).to be false + expect(safe_settings.points_rendering_mode).to eq('raw') + expect(safe_settings.minutes_between_routes).to eq(30) + expect(safe_settings.time_threshold_minutes).to eq(30) + expect(safe_settings.merge_threshold_minutes).to eq(15) + expect(safe_settings.live_map_enabled).to be true + expect(safe_settings.route_opacity).to eq(0.6) + expect(safe_settings.immich_url).to be_nil + expect(safe_settings.immich_api_key).to be_nil + expect(safe_settings.photoprism_url).to be_nil + expect(safe_settings.photoprism_api_key).to be_nil + expect(safe_settings.maps).to eq({}) + end + end + + context 'with custom values' do + let(:settings) do + { + 'fog_of_war_meters' => 100, + 'meters_between_routes' => 1000, + 'preferred_map_layer' => 'Satellite', + 'speed_colored_routes' => true, + 'points_rendering_mode' => 'simplified', + 'minutes_between_routes' => 60, + 'time_threshold_minutes' => 45, + 'merge_threshold_minutes' => 20, + 'live_map_enabled' => false, + 'route_opacity' => 0.8, + 'immich_url' => 'https://immich.example.com', + 'immich_api_key' => 'immich-key', + 'photoprism_url' => 'https://photoprism.example.com', + 'photoprism_api_key' => 'photoprism-key', + 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' } + } + end + + it 'returns custom values for each setting' do + expect(safe_settings.fog_of_war_meters).to eq(100) + expect(safe_settings.meters_between_routes).to eq(1000) + expect(safe_settings.preferred_map_layer).to eq('Satellite') + expect(safe_settings.speed_colored_routes).to be true + expect(safe_settings.points_rendering_mode).to eq('simplified') + expect(safe_settings.minutes_between_routes).to eq(60) + expect(safe_settings.time_threshold_minutes).to eq(45) + expect(safe_settings.merge_threshold_minutes).to eq(20) + expect(safe_settings.live_map_enabled).to be false + expect(safe_settings.route_opacity).to eq(0.8) + expect(safe_settings.immich_url).to eq('https://immich.example.com') + expect(safe_settings.immich_api_key).to eq('immich-key') + expect(safe_settings.photoprism_url).to eq('https://photoprism.example.com') + expect(safe_settings.photoprism_api_key).to eq('photoprism-key') + expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' }) + end + end + end +end From 5b2834599ee9d100af29633db9ca5d49cc9f4956 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 21:17:33 +0100 Subject: [PATCH 6/8] Update prometheus metric name --- CHANGELOG.md | 6 +++--- app/services/maps/tile_usage/track.rb | 2 +- spec/services/maps/tile_usage/track_spec.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269617f7..76df144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,9 @@ To set a custom tile URL, go to the user settings and set the `Maps` section to - If you have Prometheus exporter enabled, you can now see a `ruby_dawarich_map_tiles` metric in Prometheus, which shows the total number of map tiles loaded. Example: ``` -# HELP ruby_dawarich_map_tiles -# TYPE ruby_dawarich_map_tiles gauge -ruby_dawarich_map_tiles 99 +# HELP ruby_dawarich_map_tiles_usage +# TYPE ruby_dawarich_map_tiles_usage counter +ruby_dawarich_map_tiles_usage 99 ``` # 0.24.0 - 2025-02-10 diff --git a/app/services/maps/tile_usage/track.rb b/app/services/maps/tile_usage/track.rb index 13fd1711..0affd754 100644 --- a/app/services/maps/tile_usage/track.rb +++ b/app/services/maps/tile_usage/track.rb @@ -8,7 +8,7 @@ class Maps::TileUsage::Track def call metric_data = { type: 'counter', - name: 'dawarich_map_tiles', + name: 'dawarich_map_tiles_usage', value: @count } diff --git a/spec/services/maps/tile_usage/track_spec.rb b/spec/services/maps/tile_usage/track_spec.rb index 82f0360c..896810eb 100644 --- a/spec/services/maps/tile_usage/track_spec.rb +++ b/spec/services/maps/tile_usage/track_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Maps::TileUsage::Track do expect(prometheus_client).to receive(:send_json).with( { type: 'counter', - name: 'dawarich_map_tiles', + name: 'dawarich_map_tiles_usage', value: tile_count } ) From 8997d10658078267a4d90260bea204119300f07c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 21:38:41 +0100 Subject: [PATCH 7/8] Fix speed units on the Points page --- .app_version | 2 +- CHANGELOG.md | 6 +++++- app/helpers/application_helper.rb | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 2094a100..48b91fd8 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.24.0 +0.24.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 76df144a..233e7708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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.24.1 - 2025-02-10 +# 0.24.1 - 2025-02-11 ## Custom map tiles @@ -24,6 +24,10 @@ To set a custom tile URL, go to the user settings and set the `Maps` section to ruby_dawarich_map_tiles_usage 99 ``` +### Fixed + +- Speed on the Points page is now being displayed in kilometers per hour. + # 0.24.0 - 2025-02-10 ## Points speed units diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c6258d08..a4a01a5e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -120,4 +120,10 @@ module ApplicationHelper 'text-red-500' end + + def point_speed(speed) + return speed if speed.to_i <= 0 + + speed * 3.6 + end end From 2a36f961109ff4c016d7186db681fedff5adb235 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 21:39:04 +0100 Subject: [PATCH 8/8] Provide reference to the issue in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 233e7708..6fb1084b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ ruby_dawarich_map_tiles_usage 99 ### Fixed -- Speed on the Points page is now being displayed in kilometers per hour. +- Speed on the Points page is now being displayed in kilometers per hour. #700 # 0.24.0 - 2025-02-10