From cd31fb4cf0a70fdbe25f2ace12a1f94ac05e91e8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 26 Dec 2024 21:34:10 +0100 Subject: [PATCH 001/157] Add database constraints for users table --- Gemfile | 2 ++ Gemfile.lock | 6 +++++ app/models/user.rb | 5 ++++ config/initializers/strong_migrations.rb | 26 +++++++++++++++++++ ...26202204_add_database_users_constraints.rb | 8 ++++++ ...validate_add_database_users_constraints.rb | 14 ++++++++++ db/schema.rb | 4 ++- 7 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 config/initializers/strong_migrations.rb create mode 100644 db/migrate/20241226202204_add_database_users_constraints.rb create mode 100644 db/migrate/20241226202831_validate_add_database_users_constraints.rb diff --git a/Gemfile b/Gemfile index 103b7688..1066cbfa 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,7 @@ gem 'sidekiq-cron' gem 'sidekiq-limit_fetch' gem 'sprockets-rails' gem 'stimulus-rails' +gem 'strong_migrations' gem 'tailwindcss-rails' gem 'turbo-rails' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] @@ -54,6 +55,7 @@ group :test do end group :development do + gem 'database_consistency', require: false gem 'foreman' gem 'rubocop-rails', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 47ea71bd..73829ee8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,6 +109,8 @@ GEM data_migrate (11.2.0) activerecord (>= 6.1) railties (>= 6.1) + database_consistency (2.0.0) + activerecord (>= 3.2) date (3.4.1) debug (1.10.0) irb (~> 1.10) @@ -389,6 +391,8 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) + strong_migrations (2.1.0) + activerecord (>= 6.1) super_diff (0.14.0) attr_extras (>= 6.2.4) diff-lcs @@ -437,6 +441,7 @@ DEPENDENCIES bootsnap chartkick data_migrate + database_consistency debug devise dotenv-rails @@ -473,6 +478,7 @@ DEPENDENCIES simplecov sprockets-rails stimulus-rails + strong_migrations super_diff tailwindcss-rails turbo-rails diff --git a/app/models/user.rb b/app/models/user.rb index 64e45425..0dd32104 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,11 @@ class User < ApplicationRecord after_create :create_api_key before_save :strip_trailing_slashes + validates :email, presence: true + validates :reset_password_token, uniqueness: true, allow_nil: true + + attribute :admin, :boolean, default: false + def countries_visited stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact end diff --git a/config/initializers/strong_migrations.rb b/config/initializers/strong_migrations.rb new file mode 100644 index 00000000..04e43a9e --- /dev/null +++ b/config/initializers/strong_migrations.rb @@ -0,0 +1,26 @@ +# Mark existing migrations as safe +StrongMigrations.start_after = 20241225175637 + +# Set timeouts for migrations +# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user +StrongMigrations.lock_timeout = 10.seconds +StrongMigrations.statement_timeout = 1.hour + +# Analyze tables after indexes are added +# Outdated statistics can sometimes hurt performance +StrongMigrations.auto_analyze = true + +# Set the version of the production database +# so the right checks are run in development +# StrongMigrations.target_version = 10 + +# Add custom checks +# StrongMigrations.add_check do |method, args| +# if method == :add_index && args[0].to_s == "users" +# stop! "No more indexes on the users table" +# end +# end + +# Make some operations safe by default +# See https://github.com/ankane/strong_migrations#safe-by-default +# StrongMigrations.safe_by_default = true diff --git a/db/migrate/20241226202204_add_database_users_constraints.rb b/db/migrate/20241226202204_add_database_users_constraints.rb new file mode 100644 index 00000000..04247aeb --- /dev/null +++ b/db/migrate/20241226202204_add_database_users_constraints.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddDatabaseUsersConstraints < ActiveRecord::Migration[8.0] + def change + add_check_constraint :users, 'email IS NOT NULL', name: 'users_email_null', validate: false + add_check_constraint :users, 'admin IS NOT NULL', name: 'users_admin_null', validate: false + end +end diff --git a/db/migrate/20241226202831_validate_add_database_users_constraints.rb b/db/migrate/20241226202831_validate_add_database_users_constraints.rb new file mode 100644 index 00000000..d05c606b --- /dev/null +++ b/db/migrate/20241226202831_validate_add_database_users_constraints.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ValidateAddDatabaseUsersConstraints < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :users, name: 'users_email_null' + change_column_null :users, :email, false + remove_check_constraint :users, name: 'users_email_null' + end + + def down + add_check_constraint :users, 'email IS NOT NULL', name: 'users_email_null', validate: false + change_column_null :users, :email, true + end +end diff --git a/db/schema.rb b/db/schema.rb index a79c53a9..0b55b25e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_12_11_113119) do +ActiveRecord::Schema[8.0].define(version: 2024_12_26_202831) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -219,6 +219,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_11_113119) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_check_constraint "users", "admin IS NOT NULL", name: "users_admin_null", validate: false + create_table "visits", force: :cascade do |t| t.bigint "area_id" t.bigint "user_id", null: false From e59f7631b99c84650915e802110cb69b34b8fa98 Mon Sep 17 00:00:00 2001 From: Andrey Bondarenko Date: Sun, 12 Jan 2025 12:31:49 +0100 Subject: [PATCH 002/157] Update k8s deployment example Update k8s deployment example * remove unused gem-caches * update entrypoints to correspond 0.22.x * add liveness probe --- docs/How_to_install_Dawarich_in_k8s.md | 60 +++++++++----------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/docs/How_to_install_Dawarich_in_k8s.md b/docs/How_to_install_Dawarich_in_k8s.md index f21c8658..71dc40a1 100644 --- a/docs/How_to_install_Dawarich_in_k8s.md +++ b/docs/How_to_install_Dawarich_in_k8s.md @@ -36,37 +36,7 @@ spec: storageClassName: longhorn resources: requests: - storage: 15Gi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - namespace: dawarich - name: gem-cache - labels: - storage.k8s.io/name: longhorn -spec: - accessModes: - - ReadWriteOnce - storageClassName: longhorn - resources: - requests: - storage: 15Gi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - namespace: dawarich - name: gem-sidekiq - labels: - storage.k8s.io/name: longhorn -spec: - accessModes: - - ReadWriteOnce - storageClassName: longhorn - resources: - requests: - storage: 15Gi + storage: 1Gi --- apiVersion: v1 kind: PersistentVolumeClaim @@ -81,7 +51,7 @@ spec: storageClassName: longhorn resources: requests: - storage: 15Gi + storage: 1Gi ``` ### Deployment @@ -143,14 +113,12 @@ spec: image: freikin/dawarich:0.16.4 imagePullPolicy: Always volumeMounts: - - mountPath: /usr/local/bundle/gems - name: gem-app - mountPath: /var/app/public name: public - mountPath: /var/app/tmp/imports/watched name: watched command: - - "dev-entrypoint.sh" + - "web-entrypoint.sh" args: - "bin/rails server -p 3000 -b ::" resources: @@ -199,16 +167,14 @@ spec: image: freikin/dawarich:0.16.4 imagePullPolicy: Always volumeMounts: - - mountPath: /usr/local/bundle/gems - name: gem-sidekiq - mountPath: /var/app/public name: public - mountPath: /var/app/tmp/imports/watched name: watched command: - - "dev-entrypoint.sh" + - "sidekiq-entrypoint.sh" args: - - "sidekiq" + - "bundle exec sidekiq" resources: requests: memory: "1Gi" @@ -216,6 +182,22 @@ spec: limits: memory: "3Gi" cpu: "1500m" + livenessProbe: + httpGet: + path: /api/v1/health + port: 3000 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 volumes: - name: gem-cache persistentVolumeClaim: From 5a031dad6924a22d37686af0d05e15e8e52821b7 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 19 Jan 2025 11:26:38 +0100 Subject: [PATCH 003/157] Implement drag and drop for points --- app/javascript/maps/markers.js | 54 ++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js index d55ee7fb..cb319cea 100644 --- a/app/javascript/maps/markers.js +++ b/app/javascript/maps/markers.js @@ -12,13 +12,25 @@ export function createMarkersArray(markersData, userSettings) { const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit); let markerColor = marker[5] < 0 ? "orange" : "blue"; - return L.circleMarker([lat, lon], { - renderer: renderer, // Use canvas renderer - radius: 4, - color: markerColor, - zIndexOffset: 1000, - pane: 'markerPane' - }).bindPopup(popupContent, { autoClose: false }); + // Use L.marker instead of L.circleMarker for better drag support + return L.marker([lat, lon], { + icon: L.divIcon({ + className: 'custom-div-icon', + html: `
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + }), + draggable: true, + autoPan: true + }).bindPopup(popupContent, { autoClose: false }) + .on('dragstart', function(e) { + this.closePopup(); + }) + .on('dragend', function(e) { + const newLatLng = e.target.getLatLng(); + this.setLatLng(newLatLng); + this.openPopup(); + }); }); } } @@ -53,14 +65,24 @@ export function createSimplifiedMarkers(markersData, renderer) { const popupContent = createPopupContent(marker); let markerColor = marker[5] < 0 ? "orange" : "blue"; - return L.circleMarker( - [lat, lon], - { - renderer: renderer, // Use canvas renderer - radius: 4, - color: markerColor, - zIndexOffset: 1000 - } - ).bindPopup(popupContent); + // Use L.marker instead of L.circleMarker for better drag support + return L.marker([lat, lon], { + icon: L.divIcon({ + className: 'custom-div-icon', + html: `
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + }), + draggable: true, + autoPan: true + }).bindPopup(popupContent) + .on('dragstart', function(e) { + this.closePopup(); + }) + .on('dragend', function(e) { + const newLatLng = e.target.getLatLng(); + this.setLatLng(newLatLng); + this.openPopup(); + }); }); } From f85fd9e4d0d8d2da5c60f2019a6edbe10a45544d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 19 Jan 2025 12:59:12 +0100 Subject: [PATCH 004/157] Implement polylines redraw on point drag --- app/controllers/api/v1/points_controller.rb | 12 ++ app/javascript/controllers/maps_controller.js | 2 +- app/javascript/maps/markers.js | 172 +++++++++++++++++- config/routes.rb | 2 +- 4 files changed, 179 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index a70dabdc..7905ca68 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -21,6 +21,14 @@ class Api::V1::PointsController < ApiController render json: serialized_points end + def update + point = current_api_user.tracked_points.find(params[:id]) + + point.update(point_params) + + render json: point_serializer.new(point).call + end + def destroy point = current_api_user.tracked_points.find(params[:id]) point.destroy @@ -30,6 +38,10 @@ class Api::V1::PointsController < ApiController private + def point_params + params.require(:point).permit(:latitude, :longitude) + end + def point_serializer params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 01fa6ad7..8a0afdf0 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -68,7 +68,7 @@ export default class extends Controller { this.map.setMaxBounds(bounds); - this.markersArray = createMarkersArray(this.markers, this.userSettings); + this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey); this.markersLayer = L.layerGroup(this.markersArray); this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]); diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js index cb319cea..d4e67380 100644 --- a/app/javascript/maps/markers.js +++ b/app/javascript/maps/markers.js @@ -1,18 +1,18 @@ import { createPopupContent } from "./popups"; -export function createMarkersArray(markersData, userSettings) { +export function createMarkersArray(markersData, userSettings, apiKey) { // Create a canvas renderer const renderer = L.canvas({ padding: 0.5 }); if (userSettings.pointsRenderingMode === "simplified") { return createSimplifiedMarkers(markersData, renderer); } else { - return markersData.map((marker) => { + return markersData.map((marker, index) => { const [lat, lon] = marker; + const pointId = marker[2]; const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit); let markerColor = marker[5] < 0 ? "orange" : "blue"; - // Use L.marker instead of L.circleMarker for better drag support return L.marker([lat, lon], { icon: L.divIcon({ className: 'custom-div-icon', @@ -21,20 +21,179 @@ export function createMarkersArray(markersData, userSettings) { iconAnchor: [4, 4] }), draggable: true, - autoPan: true - }).bindPopup(popupContent, { autoClose: false }) + autoPan: true, + pointIndex: index, + pointId: pointId, + originalLat: lat, + originalLng: lon, + renderer: renderer + }).bindPopup(popupContent) .on('dragstart', function(e) { + console.log('Drag started', { index: this.options.pointIndex }); this.closePopup(); }) - .on('dragend', function(e) { + .on('drag', function(e) { const newLatLng = e.target.getLatLng(); + const map = e.target._map; + const pointIndex = e.target.options.pointIndex; + const originalLat = e.target.options.originalLat; + const originalLng = e.target.options.originalLng; + + console.log('Dragging point', { + pointIndex, + newPosition: newLatLng, + originalPosition: { lat: originalLat, lng: originalLng } + }); + + // Find polylines by iterating through all map layers + map.eachLayer((layer) => { + // Check if this is a LayerGroup containing polylines + if (layer instanceof L.LayerGroup) { + layer.eachLayer((featureGroup) => { + if (featureGroup instanceof L.FeatureGroup) { + featureGroup.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + const coords = segment.getLatLngs(); + const tolerance = 0.0000001; + let updated = false; + + // Check and update start point + if (Math.abs(coords[0].lat - originalLat) < tolerance && + Math.abs(coords[0].lng - originalLng) < tolerance) { + console.log('Updating start point of segment', { + from: coords[0], + to: newLatLng + }); + coords[0] = newLatLng; + updated = true; + } + + // Check and update end point + if (Math.abs(coords[1].lat - originalLat) < tolerance && + Math.abs(coords[1].lng - originalLng) < tolerance) { + console.log('Updating end point of segment', { + from: coords[1], + to: newLatLng + }); + coords[1] = newLatLng; + updated = true; + } + + // Only update if we found a matching endpoint + if (updated) { + segment.setLatLngs(coords); + segment.redraw(); + } + } + }); + } + }); + } + }); + + // Update the marker's original position for the next drag event + e.target.options.originalLat = newLatLng.lat; + e.target.options.originalLng = newLatLng.lng; + }) + .on('dragend', function(e) { + console.log('Drag ended', { + finalPosition: e.target.getLatLng(), + pointIndex: e.target.options.pointIndex + }); + const newLatLng = e.target.getLatLng(); + const pointId = e.target.options.pointId; + const pointIndex = e.target.options.pointIndex; + + // Update the marker's position this.setLatLng(newLatLng); this.openPopup(); + + // Send API request to update point position + fetch(`/api/v1/points/${pointId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + point: { + latitude: newLatLng.lat, + longitude: newLatLng.lng + } + }) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update point position'); + } + return response.json(); + }) + .then(data => { + // Update the markers array in the controller + const map = e.target._map; + const mapsController = map.mapsController; + if (mapsController && mapsController.markers) { + mapsController.markers[pointIndex][0] = newLatLng.lat; + mapsController.markers[pointIndex][1] = newLatLng.lng; + + // Store current polylines visibility state + const wasPolyLayerVisible = map.hasLayer(mapsController.polylinesLayer); + + // Remove old polylines layer + if (mapsController.polylinesLayer) { + map.removeLayer(mapsController.polylinesLayer); + } + + // Create new polylines layer with updated coordinates + mapsController.polylinesLayer = createPolylinesLayer( + mapsController.markers, + map, + mapsController.timezone, + mapsController.routeOpacity, + mapsController.userSettings, + mapsController.distanceUnit + ); + + // Restore polylines visibility if it was visible before + if (wasPolyLayerVisible) { + mapsController.polylinesLayer.addTo(map); + } + } + + // Update popup content with new data + const updatedPopupContent = createPopupContent( + [ + data.latitude, + data.longitude, + data.id, + data.altitude, + data.timestamp, + data.velocity || 0 + ], + userSettings.timezone, + userSettings.distanceUnit + ); + this.setPopupContent(updatedPopupContent); + }) + .catch(error => { + console.error('Error updating point position:', error); + // Revert the marker position on error + this.setLatLng([lat, lon]); + }); }); }); } } +// Helper function to check if a point is connected to a polyline endpoint +function isConnectedToPoint(latLng, originalPoint, tolerance) { + // originalPoint is [lat, lng] array + const latMatch = Math.abs(latLng.lat - originalPoint[0]) < tolerance; + const lngMatch = Math.abs(latLng.lng - originalPoint[1]) < tolerance; + return latMatch && lngMatch; +} + export function createSimplifiedMarkers(markersData, renderer) { const distanceThreshold = 50; // meters const timeThreshold = 20000; // milliseconds (3 seconds) @@ -47,7 +206,6 @@ export function createSimplifiedMarkers(markersData, renderer) { if (index === 0) return; // Skip the first marker const [prevLat, prevLon, prevTimestamp] = previousMarker; - const [currLat, currLon, currTimestamp] = currentMarker; const timeDiff = currTimestamp - prevTimestamp; const distance = haversineDistance(prevLat, prevLon, currLat, currLon, 'km') * 1000; // Convert km to meters diff --git a/config/routes.rb b/config/routes.rb index 8d28efde..0befcca4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,7 +67,7 @@ Rails.application.routes.draw do get 'settings', to: 'settings#index' resources :areas, only: %i[index create update destroy] - resources :points, only: %i[index destroy] + resources :points, only: %i[index destroy update] resources :visits, only: %i[update] resources :stats, only: :index From 94e08d56e112dcc91728d41f589fe4745fae5034 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 19 Jan 2025 17:14:09 +0100 Subject: [PATCH 005/157] Fix point updating in the database --- app/javascript/maps/markers.js | 116 ++++++++++++--------------------- 1 file changed, 40 insertions(+), 76 deletions(-) diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js index d4e67380..610a81dc 100644 --- a/app/javascript/maps/markers.js +++ b/app/javascript/maps/markers.js @@ -9,9 +9,8 @@ export function createMarkersArray(markersData, userSettings, apiKey) { } else { return markersData.map((marker, index) => { const [lat, lon] = marker; - const pointId = marker[2]; - const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit); - let markerColor = marker[5] < 0 ? "orange" : "blue"; + const pointId = marker[6]; // ID is at index 6 + const markerColor = marker[5] < 0 ? "orange" : "blue"; return L.marker([lat, lon], { icon: L.divIcon({ @@ -26,10 +25,10 @@ export function createMarkersArray(markersData, userSettings, apiKey) { pointId: pointId, originalLat: lat, originalLng: lon, + markerData: marker, // Store the complete marker data renderer: renderer - }).bindPopup(popupContent) + }).bindPopup(createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit)) .on('dragstart', function(e) { - console.log('Drag started', { index: this.options.pointIndex }); this.closePopup(); }) .on('drag', function(e) { @@ -38,13 +37,6 @@ export function createMarkersArray(markersData, userSettings, apiKey) { const pointIndex = e.target.options.pointIndex; const originalLat = e.target.options.originalLat; const originalLng = e.target.options.originalLng; - - console.log('Dragging point', { - pointIndex, - newPosition: newLatLng, - originalPosition: { lat: originalLat, lng: originalLng } - }); - // Find polylines by iterating through all map layers map.eachLayer((layer) => { // Check if this is a LayerGroup containing polylines @@ -60,10 +52,6 @@ export function createMarkersArray(markersData, userSettings, apiKey) { // Check and update start point if (Math.abs(coords[0].lat - originalLat) < tolerance && Math.abs(coords[0].lng - originalLng) < tolerance) { - console.log('Updating start point of segment', { - from: coords[0], - to: newLatLng - }); coords[0] = newLatLng; updated = true; } @@ -71,10 +59,6 @@ export function createMarkersArray(markersData, userSettings, apiKey) { // Check and update end point if (Math.abs(coords[1].lat - originalLat) < tolerance && Math.abs(coords[1].lng - originalLng) < tolerance) { - console.log('Updating end point of segment', { - from: coords[1], - to: newLatLng - }); coords[1] = newLatLng; updated = true; } @@ -96,19 +80,11 @@ export function createMarkersArray(markersData, userSettings, apiKey) { e.target.options.originalLng = newLatLng.lng; }) .on('dragend', function(e) { - console.log('Drag ended', { - finalPosition: e.target.getLatLng(), - pointIndex: e.target.options.pointIndex - }); const newLatLng = e.target.getLatLng(); const pointId = e.target.options.pointId; const pointIndex = e.target.options.pointIndex; + const originalMarkerData = e.target.options.markerData; - // Update the marker's position - this.setLatLng(newLatLng); - this.openPopup(); - - // Send API request to update point position fetch(`/api/v1/points/${pointId}`, { method: 'PATCH', headers: { @@ -118,68 +94,56 @@ export function createMarkersArray(markersData, userSettings, apiKey) { }, body: JSON.stringify({ point: { - latitude: newLatLng.lat, - longitude: newLatLng.lng + latitude: newLatLng.lat.toString(), + longitude: newLatLng.lng.toString() } }) }) .then(response => { if (!response.ok) { - throw new Error('Failed to update point position'); + throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(data => { - // Update the markers array in the controller const map = e.target._map; - const mapsController = map.mapsController; - if (mapsController && mapsController.markers) { - mapsController.markers[pointIndex][0] = newLatLng.lat; - mapsController.markers[pointIndex][1] = newLatLng.lng; - - // Store current polylines visibility state - const wasPolyLayerVisible = map.hasLayer(mapsController.polylinesLayer); - - // Remove old polylines layer - if (mapsController.polylinesLayer) { - map.removeLayer(mapsController.polylinesLayer); - } - - // Create new polylines layer with updated coordinates - mapsController.polylinesLayer = createPolylinesLayer( - mapsController.markers, - map, - mapsController.timezone, - mapsController.routeOpacity, - mapsController.userSettings, - mapsController.distanceUnit - ); - - // Restore polylines visibility if it was visible before - if (wasPolyLayerVisible) { - mapsController.polylinesLayer.addTo(map); + if (map && map.mapsController && map.mapsController.markers) { + const markers = map.mapsController.markers; + if (markers[pointIndex]) { + markers[pointIndex][0] = parseFloat(data.latitude); + markers[pointIndex][1] = parseFloat(data.longitude); } } - // Update popup content with new data - const updatedPopupContent = createPopupContent( - [ - data.latitude, - data.longitude, - data.id, - data.altitude, - data.timestamp, - data.velocity || 0 - ], - userSettings.timezone, - userSettings.distanceUnit - ); - this.setPopupContent(updatedPopupContent); + // Create updated marker data array + const updatedMarkerData = [ + parseFloat(data.latitude), + parseFloat(data.longitude), + originalMarkerData[2], // battery + originalMarkerData[3], // altitude + originalMarkerData[4], // timestamp + originalMarkerData[5], // velocity + data.id, // id + originalMarkerData[7] // country + ]; + + // Update the marker's stored data + e.target.options.markerData = updatedMarkerData; + + // Update the popup content + if (this._popup) { + const updatedPopupContent = createPopupContent( + updatedMarkerData, + userSettings.timezone, + userSettings.distanceUnit + ); + this.setPopupContent(updatedPopupContent); + } }) .catch(error => { - console.error('Error updating point position:', error); - // Revert the marker position on error - this.setLatLng([lat, lon]); + console.error('Error updating point:', error); + this.setLatLng([e.target.options.originalLat, e.target.options.originalLng]); + alert('Failed to update point position. Please try again.'); }); }); }); From 64b141fa24e3cc0ede78baa24b6720f44ff7f2fa Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 19 Jan 2025 17:49:11 +0100 Subject: [PATCH 006/157] Highlight routes again --- app/javascript/maps/polylines.js | 179 ++++++++++++++++++++++--------- app/javascript/maps/popups.js | 3 + 2 files changed, 130 insertions(+), 52 deletions(-) diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index ba7e15cf..50abecc9 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -170,51 +170,54 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use let hoverPopup = null; - polylineGroup.on("mouseover", function (e) { - let closestSegment = null; - let minDistance = Infinity; - let currentSpeed = 0; + // Add events to both group and individual polylines + polylineGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + layer.on("mouseover", function (e) { + console.log("Individual polyline mouseover", e); + handleMouseOver(e); + }); - polylineGroup.eachLayer((layer) => { - if (layer instanceof L.Polyline) { - const layerLatLngs = layer.getLatLngs(); - const distance = pointToLineDistance(e.latlng, layerLatLngs[0], layerLatLngs[1]); + layer.on("mouseout", function (e) { + console.log("Individual polyline mouseout", e); + handleMouseOut(e); + }); + } + }); - if (distance < minDistance) { - minDistance = distance; - closestSegment = layer; + function handleMouseOver(e) { + console.log('Individual polyline mouseover', e); - const startIdx = polylineCoordinates.findIndex(p => { - const latMatch = Math.abs(p[0] - layerLatLngs[0].lat) < 0.0000001; - const lngMatch = Math.abs(p[1] - layerLatLngs[0].lng) < 0.0000001; - return latMatch && lngMatch; - }); + // Handle both direct layer events and group propagated events + const layer = e.layer || e.target; + let speed = 0; - if (startIdx !== -1 && startIdx < polylineCoordinates.length - 1) { - currentSpeed = calculateSpeed( - polylineCoordinates[startIdx], - polylineCoordinates[startIdx + 1] - ); - } + if (layer instanceof L.Polyline) { + // Get the coordinates array from the layer + const coords = layer.getLatLngs(); + if (coords && coords.length >= 2) { + const startPoint = coords[0]; + const endPoint = coords[coords.length - 1]; + + // Find the corresponding markers for these coordinates + const startMarkerData = polylineCoordinates.find(m => + m[0] === startPoint.lat && m[1] === startPoint.lng + ); + const endMarkerData = polylineCoordinates.find(m => + m[0] === endPoint.lat && m[1] === endPoint.lng + ); + + // Calculate speed if we have both markers + if (startMarkerData && endMarkerData) { + speed = startMarkerData[5] || endMarkerData[5] || 0; } } - }); + } - // Apply highlight style to all segments - polylineGroup.eachLayer((layer) => { - if (layer instanceof L.Polyline) { - const highlightStyle = { - weight: 5, - opacity: 1 - }; - - // Only change color to yellow if speed colors are disabled - if (!userSettings.speed_colored_routes) { - highlightStyle.color = '#ffff00'; - } - - layer.setStyle(highlightStyle); - } + layer.setStyle({ + weight: 8, + opacity: 0.8, + color: '#FFD700' }); startMarker.addTo(map); @@ -225,7 +228,7 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use End: ${lastTimestamp}
Duration: ${timeOnRoute}
Total Distance: ${formatDistance(totalDistance, distanceUnit)}
- Current Speed: ${Math.round(currentSpeed)} km/h + Current Speed: ${Math.round(speed)} km/h `; if (hoverPopup) { @@ -236,16 +239,15 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use .setLatLng(e.latlng) .setContent(popupContent) .openOn(map); - }); + } - polylineGroup.on("mouseout", function () { - // Restore original style + function handleMouseOut(e) { polylineGroup.eachLayer((layer) => { if (layer instanceof L.Polyline) { const originalStyle = { weight: 3, opacity: userSettings.route_opacity, - color: layer.options.originalColor // Use the stored original color + color: layer.options.originalColor }; layer.setStyle(originalStyle); @@ -257,16 +259,31 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use } map.removeLayer(startMarker); map.removeLayer(endMarker); - }); + } + // Keep the original group events as a fallback + polylineGroup.on("mouseover", handleMouseOver); + polylineGroup.on("mouseout", handleMouseOut); + + // Keep the click event polylineGroup.on("click", function () { map.fitBounds(polylineGroup.getBounds()); }); } export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) { - // Create a canvas renderer - const renderer = L.canvas({ padding: 0.5 }); + // Create a custom pane for our polylines with higher z-index + if (!map.getPane('polylinesPane')) { + map.createPane('polylinesPane'); + map.getPane('polylinesPane').style.zIndex = 450; // Above the default overlay pane (400) + } + + const renderer = L.canvas({ + padding: 0.5, + pane: 'polylinesPane' + }); + + console.log("Creating polylines layer with markers:", markers.length); const splitPolylines = []; let currentPolyline = []; @@ -295,9 +312,15 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS splitPolylines.push(currentPolyline); } - return L.layerGroup( - splitPolylines.map((polylineCoordinates) => { + console.log("Split into polyline groups:", splitPolylines.length); + + // Create the layer group with the polylines + const layerGroup = L.layerGroup( + splitPolylines.map((polylineCoordinates, groupIndex) => { + console.log(`Creating group ${groupIndex} with coordinates:`, polylineCoordinates.length); + const segmentGroup = L.featureGroup(); + const segments = []; for (let i = 0; i < polylineCoordinates.length - 1; i++) { const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]); @@ -309,25 +332,77 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS [polylineCoordinates[i + 1][0], polylineCoordinates[i + 1][1]] ], { - renderer: renderer, // Use canvas renderer + renderer: renderer, color: color, originalColor: color, opacity: routeOpacity, weight: 3, speed: speed, - startTime: polylineCoordinates[i][4], - endTime: polylineCoordinates[i + 1][4] + interactive: true, + pane: 'polylinesPane', + bubblingMouseEvents: false } ); + segments.push(segment); segmentGroup.addLayer(segment); } + // Add mouseover/mouseout to the entire group + segmentGroup.on('mouseover', function(e) { + console.log("Group mouseover", groupIndex); + L.DomEvent.stopPropagation(e); + segments.forEach(segment => { + segment.setStyle({ + weight: 8, + opacity: 1 + }); + if (map.hasLayer(segment)) { + segment.bringToFront(); + } + }); + }); + + segmentGroup.on('mouseout', function(e) { + console.log("Group mouseout", groupIndex); + L.DomEvent.stopPropagation(e); + segments.forEach(segment => { + segment.setStyle({ + weight: 3, + opacity: routeOpacity, + color: segment.options.originalColor + }); + }); + }); + + // Make the group interactive + segmentGroup.options.interactive = true; + segmentGroup.options.bubblingMouseEvents = false; + + // Add the hover functionality to the group addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit); return segmentGroup; }) - ).addTo(map); + ); + + // Add CSS to ensure our pane receives mouse events + const style = document.createElement('style'); + style.textContent = ` + .leaflet-polylinesPane-pane { + pointer-events: auto !important; + } + .leaflet-polylinesPane-pane canvas { + pointer-events: auto !important; + } + `; + document.head.appendChild(style); + + // Add to map and return + layerGroup.addTo(map); + console.log("Layer group added to map"); + + return layerGroup; } export function updatePolylinesColors(polylinesLayer, useSpeedColors) { diff --git a/app/javascript/maps/popups.js b/app/javascript/maps/popups.js index dee74dc5..cba49a22 100644 --- a/app/javascript/maps/popups.js +++ b/app/javascript/maps/popups.js @@ -8,6 +8,9 @@ export function createPopupContent(marker, timezone, distanceUnit) { marker[3] = marker[3] * 3.28084; } + // convert marker[5] from m/s to km/h and round to nearest integer + marker[5] = Math.round(marker[5] * 3.6); + return ` Timestamp: ${formatDate(marker[4], timezone)}
Latitude: ${marker[0]}
From 3ba42dceaf7e794ca257ba3e9d6ab877d5418c03 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 19 Jan 2025 18:05:21 +0100 Subject: [PATCH 007/157] Highlight on hover --- app/javascript/maps/polylines.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index 50abecc9..2780dc1e 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -214,10 +214,22 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use } } - layer.setStyle({ - weight: 8, - opacity: 0.8, - color: '#FFD700' + // Apply style to all segments in the group + polylineGroup.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + const newStyle = { + weight: 8, + opacity: 0.8 + }; + + // Only change color if speed-colored routes are not enabled + console.log("speed_colored_routes", userSettings.speed_colored_routes); + if (!userSettings.speed_colored_routes) { + newStyle.color = "yellow" + } + + segment.setStyle(newStyle); + } }); startMarker.addTo(map); From 70caaef9b5a6b2ba0f6c20463f7117d5cb328b21 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 19 Jan 2025 18:06:56 +0100 Subject: [PATCH 008/157] Remove console logs --- app/javascript/maps/polylines.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index 2780dc1e..4b548264 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -174,20 +174,16 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use polylineGroup.eachLayer((layer) => { if (layer instanceof L.Polyline) { layer.on("mouseover", function (e) { - console.log("Individual polyline mouseover", e); handleMouseOver(e); }); layer.on("mouseout", function (e) { - console.log("Individual polyline mouseout", e); handleMouseOut(e); }); } }); function handleMouseOver(e) { - console.log('Individual polyline mouseover', e); - // Handle both direct layer events and group propagated events const layer = e.layer || e.target; let speed = 0; @@ -219,11 +215,10 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use if (segment instanceof L.Polyline) { const newStyle = { weight: 8, - opacity: 0.8 + opacity: 1 }; // Only change color if speed-colored routes are not enabled - console.log("speed_colored_routes", userSettings.speed_colored_routes); if (!userSettings.speed_colored_routes) { newStyle.color = "yellow" } @@ -295,8 +290,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS pane: 'polylinesPane' }); - console.log("Creating polylines layer with markers:", markers.length); - const splitPolylines = []; let currentPolyline = []; const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500; @@ -324,13 +317,9 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS splitPolylines.push(currentPolyline); } - console.log("Split into polyline groups:", splitPolylines.length); - // Create the layer group with the polylines const layerGroup = L.layerGroup( splitPolylines.map((polylineCoordinates, groupIndex) => { - console.log(`Creating group ${groupIndex} with coordinates:`, polylineCoordinates.length); - const segmentGroup = L.featureGroup(); const segments = []; @@ -362,7 +351,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS // Add mouseover/mouseout to the entire group segmentGroup.on('mouseover', function(e) { - console.log("Group mouseover", groupIndex); L.DomEvent.stopPropagation(e); segments.forEach(segment => { segment.setStyle({ @@ -376,7 +364,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS }); segmentGroup.on('mouseout', function(e) { - console.log("Group mouseout", groupIndex); L.DomEvent.stopPropagation(e); segments.forEach(segment => { segment.setStyle({ @@ -412,7 +399,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS // Add to map and return layerGroup.addTo(map); - console.log("Layer group added to map"); return layerGroup; } From a50e9f664add44f0aed9e60a3c325cae94f4c978 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 11:26:52 +0100 Subject: [PATCH 009/157] Highlight route on click --- app/javascript/maps/polylines.js | 213 +++++++++++++++++++++++-------- 1 file changed, 158 insertions(+), 55 deletions(-) diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index 4b548264..e48479d3 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -169,6 +169,7 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }); let hoverPopup = null; + let clickedLayer = null; // Add events to both group and individual polylines polylineGroup.eachLayer((layer) => { @@ -180,6 +181,10 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use layer.on("mouseout", function (e) { handleMouseOut(e); }); + + layer.on("click", function (e) { + handleClick(e); + }); } }); @@ -189,44 +194,140 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use let speed = 0; if (layer instanceof L.Polyline) { - // Get the coordinates array from the layer - const coords = layer.getLatLngs(); - if (coords && coords.length >= 2) { - const startPoint = coords[0]; - const endPoint = coords[coords.length - 1]; + // Get the coordinates array from the layer + const coords = layer.getLatLngs(); + if (coords && coords.length >= 2) { + const startPoint = coords[0]; + const endPoint = coords[coords.length - 1]; - // Find the corresponding markers for these coordinates - const startMarkerData = polylineCoordinates.find(m => - m[0] === startPoint.lat && m[1] === startPoint.lng - ); - const endMarkerData = polylineCoordinates.find(m => - m[0] === endPoint.lat && m[1] === endPoint.lng - ); + // Find the corresponding markers for these coordinates + const startMarkerData = polylineCoordinates.find(m => + m[0] === startPoint.lat && m[1] === startPoint.lng + ); + const endMarkerData = polylineCoordinates.find(m => + m[0] === endPoint.lat && m[1] === endPoint.lng + ); - // Calculate speed if we have both markers - if (startMarkerData && endMarkerData) { - speed = startMarkerData[5] || endMarkerData[5] || 0; + // Calculate speed if we have both markers + if (startMarkerData && endMarkerData) { + speed = startMarkerData[5] || endMarkerData[5] || 0; + } } - } } - // Apply style to all segments in the group - polylineGroup.eachLayer((segment) => { - if (segment instanceof L.Polyline) { - const newStyle = { - weight: 8, - opacity: 1 - }; + // Don't apply hover styles if this is the clicked layer + if (!clickedLayer) { + // Apply style to all segments in the group + polylineGroup.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + const newStyle = { + weight: 8, + opacity: 1 + }; - // Only change color if speed-colored routes are not enabled - if (!userSettings.speed_colored_routes) { - newStyle.color = "yellow" + // Only change color if speed-colored routes are not enabled + if (!userSettings.speed_colored_routes) { + newStyle.color = 'yellow'; // Highlight color + } + + segment.setStyle(newStyle); + } + }); + + startMarker.addTo(map); + endMarker.addTo(map); + + const popupContent = ` + Start: ${firstTimestamp}
+ End: ${lastTimestamp}
+ Duration: ${timeOnRoute}
+ Total Distance: ${formatDistance(totalDistance, distanceUnit)}
+ Current Speed: ${Math.round(speed)} km/h + `; + + if (hoverPopup) { + map.closePopup(hoverPopup); } - segment.setStyle(newStyle); - } + hoverPopup = L.popup() + .setLatLng(e.latlng) + .setContent(popupContent) + .openOn(map); + } + } + + function handleMouseOut(e) { + // If there's a clicked state, maintain it + if (clickedLayer && polylineGroup.clickedState) { + polylineGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + if (layer === clickedLayer || layer.options.originalPath === clickedLayer.options.originalPath) { + layer.setStyle(polylineGroup.clickedState.style); + } + } + }); + return; + } + + // Apply normal style only if there's no clicked layer + polylineGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + const originalStyle = { + weight: 3, + opacity: userSettings.route_opacity, + color: layer.options.originalColor + }; + layer.setStyle(originalStyle); + } }); + if (hoverPopup && !clickedLayer) { + map.closePopup(hoverPopup); + map.removeLayer(startMarker); + map.removeLayer(endMarker); + } + } + + function handleClick(e) { + const newClickedLayer = e.target; + + // If clicking the same route that's already clicked, do nothing + if (clickedLayer === newClickedLayer) { + return; + } + + // Store reference to previous clicked layer before updating + const previousClickedLayer = clickedLayer; + + // Update clicked layer reference + clickedLayer = newClickedLayer; + + // Reset previous clicked layer if it exists + if (previousClickedLayer) { + previousClickedLayer.setStyle({ + weight: 3, + opacity: userSettings.route_opacity, + color: previousClickedLayer.options.originalColor + }); + } + + // Define style for clicked state + const clickedStyle = { + weight: 8, + opacity: 1, + color: userSettings.speed_colored_routes ? clickedLayer.options.originalColor : 'yellow' + }; + + // Apply style to new clicked layer + clickedLayer.setStyle(clickedStyle); + clickedLayer.bringToFront(); + + // Update clicked state + polylineGroup.clickedState = { + layer: clickedLayer, + style: clickedStyle + }; + startMarker.addTo(map); endMarker.addTo(map); @@ -235,7 +336,7 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use End: ${lastTimestamp}
Duration: ${timeOnRoute}
Total Distance: ${formatDistance(totalDistance, distanceUnit)}
- Current Speed: ${Math.round(speed)} km/h + Current Speed: ${Math.round(clickedLayer.options.speed || 0)} km/h `; if (hoverPopup) { @@ -243,39 +344,41 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use } hoverPopup = L.popup() - .setLatLng(e.latlng) - .setContent(popupContent) - .openOn(map); + .setLatLng(e.latlng) + .setContent(popupContent) + .openOn(map); + + // Prevent the click event from propagating to the map + L.DomEvent.stopPropagation(e); } - function handleMouseOut(e) { - polylineGroup.eachLayer((layer) => { - if (layer instanceof L.Polyline) { - const originalStyle = { - weight: 3, - opacity: userSettings.route_opacity, - color: layer.options.originalColor - }; - - layer.setStyle(originalStyle); - } - }); - - if (hoverPopup) { - map.closePopup(hoverPopup); + // Reset highlight when clicking elsewhere on the map + map.on('click', function () { + if (clickedLayer) { + const clickedGroup = clickedLayer.polylineGroup || polylineGroup; + clickedGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + layer.setStyle({ + weight: 3, + opacity: userSettings.route_opacity, + color: layer.options.originalColor + }); + } + }); + clickedLayer = null; + clickedGroup.clickedState = null; } - map.removeLayer(startMarker); - map.removeLayer(endMarker); - } + if (hoverPopup) { + map.closePopup(hoverPopup); + map.removeLayer(startMarker); + map.removeLayer(endMarker); + } + }); // Keep the original group events as a fallback polylineGroup.on("mouseover", handleMouseOver); polylineGroup.on("mouseout", handleMouseOut); - - // Keep the click event - polylineGroup.on("click", function () { - map.fitBounds(polylineGroup.getBounds()); - }); + polylineGroup.on("click", handleClick); } export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) { From 0e5381c16f1ba83f3e618d2fae5db78cfb61d6de Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 11:41:45 +0100 Subject: [PATCH 010/157] Fix fog of war console error --- app/javascript/controllers/maps_controller.js | 289 ++++++------------ app/javascript/maps/fog_of_war.js | 16 +- 2 files changed, 106 insertions(+), 199 deletions(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 8a0afdf0..0fd4502f 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -98,35 +98,41 @@ export default class extends Controller { Photos: this.photoMarkers }; - // Add this new custom control BEFORE the scale control - const TestControl = L.Control.extend({ - onAdd: (map) => { - const div = L.DomUtil.create('div', 'leaflet-control'); - const distance = this.element.dataset.distance || '0'; - const pointsNumber = this.element.dataset.points_number || '0'; - const unit = this.distanceUnit === 'mi' ? 'mi' : 'km'; - div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`; - div.style.backgroundColor = 'white'; - div.style.padding = '0 5px'; - div.style.marginRight = '5px'; - div.style.display = 'inline-block'; - return div; + // Initialize layer control first + this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); + + // Add the toggle panel button + this.addTogglePanelButton(); + + // Check if we should open the panel based on localStorage or URL params + const urlParams = new URLSearchParams(window.location.search); + const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; + const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); + + // Always create the panel first + this.toggleRightPanel(); + + // Then hide it if it shouldn't be open + if (!isPanelOpen && !hasDateParams) { + const panel = document.querySelector('.leaflet-right-panel'); + if (panel) { + panel.style.display = 'none'; + localStorage.setItem('mapPanelOpen', 'false'); + } + } + + // Update event handlers + this.map.on('moveend', () => { + if (document.getElementById('fog')) { + this.updateFog(this.markers, this.clearFogRadius); } }); - // Add the test control first - new TestControl({ position: 'bottomright' }).addTo(this.map); - - // Then add scale control - L.control.scale({ - position: 'bottomright', - imperial: this.distanceUnit === 'mi', - metric: this.distanceUnit === 'km', - maxWidth: 120 - }).addTo(this.map) - - // Initialize layer control - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); + this.map.on('zoomend', () => { + if (document.getElementById('fog')) { + this.updateFog(this.markers, this.clearFogRadius); + } + }); // Fetch and draw areas when the map is loaded fetchAndDrawAreas(this.areasLayer, this.apiKey); @@ -205,39 +211,6 @@ export default class extends Controller { if (this.liveMapEnabled) { this.setupSubscription(); } - - // Add the toggle panel button - this.addTogglePanelButton(); - - // Check if we should open the panel based on localStorage or URL params - const urlParams = new URLSearchParams(window.location.search); - const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; - const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); - - // Always create the panel first - this.toggleRightPanel(); - - // Then hide it if it shouldn't be open - if (!isPanelOpen && !hasDateParams) { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'none'; - localStorage.setItem('mapPanelOpen', 'false'); - } - } - - // Update event handlers - this.map.on('moveend', () => { - if (document.getElementById('fog')) { - this.updateFog(this.markers, this.clearFogRadius); - } - }); - - this.map.on('zoomend', () => { - if (document.getElementById('fog')) { - this.updateFog(this.markers, this.clearFogRadius); - } - }); } disconnect() { @@ -786,164 +759,84 @@ export default class extends Controller { } updateMapWithNewSettings(newSettings) { - console.log('Updating map settings:', { - newSettings, - currentSettings: this.userSettings, - hasPolylines: !!this.polylinesLayer, - isVisible: this.polylinesLayer && this.map.hasLayer(this.polylinesLayer) - }); - // Show loading indicator const loadingDiv = document.createElement('div'); loadingDiv.className = 'map-loading-overlay'; loadingDiv.innerHTML = '
Updating map...
'; document.body.appendChild(loadingDiv); - // Debounce the heavy operations - const updateLayers = debounce(() => { - try { - // Store current layer visibility states - const layerStates = { - Points: this.map.hasLayer(this.markersLayer), - Routes: this.map.hasLayer(this.polylinesLayer), - Heatmap: this.map.hasLayer(this.heatmapLayer), - "Fog of War": this.map.hasLayer(this.fogOverlay), - "Scratch map": this.map.hasLayer(this.scratchLayer), - Areas: this.map.hasLayer(this.areasLayer), - Photos: this.map.hasLayer(this.photoMarkers) - }; - - // Check if speed_colored_routes setting has changed - if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) { - if (this.polylinesLayer) { - updatePolylinesColors( - this.polylinesLayer, - newSettings.speed_colored_routes - ); - } + try { + // Update settings first + if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) { + if (this.polylinesLayer) { + updatePolylinesColors( + this.polylinesLayer, + newSettings.speed_colored_routes + ); } - - // Update opacity if changed - if (newSettings.route_opacity !== this.userSettings.route_opacity) { - const newOpacity = parseFloat(newSettings.route_opacity) || 0.6; - if (this.polylinesLayer) { - updatePolylinesOpacity(this.polylinesLayer, newOpacity); - } - } - - // Update the local settings - this.userSettings = { ...this.userSettings, ...newSettings }; - this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; - this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; - - // Remove existing layer control - if (this.layerControl) { - this.map.removeControl(this.layerControl); - } - - // Create new controls layer object with proper initialization - const controlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": new this.fogOverlay(), - "Scratch map": this.scratchLayer || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup() - }; - - // Add new layer control - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - - // Restore layer visibility states - Object.entries(layerStates).forEach(([name, wasVisible]) => { - const layer = controlsLayer[name]; - if (wasVisible && layer) { - layer.addTo(this.map); - } else if (layer && this.map.hasLayer(layer)) { - this.map.removeLayer(layer); - } - }); - - } catch (error) { - console.error('Error updating map settings:', error); - console.error(error.stack); - } finally { - // Remove loading indicator after all updates are complete - setTimeout(() => { - document.body.removeChild(loadingDiv); - }, 500); // Give a small delay to ensure all batches are processed } - }, 250); - updateLayers(); - } - - getLayerControlStates() { - const controls = {}; - - this.map.eachLayer((layer) => { - const layerName = this.getLayerName(layer); - - if (layerName) { - controls[layerName] = this.map.hasLayer(layer); + if (newSettings.route_opacity !== this.userSettings.route_opacity) { + const newOpacity = parseFloat(newSettings.route_opacity) || 0.6; + if (this.polylinesLayer) { + updatePolylinesOpacity(this.polylinesLayer, newOpacity); + } } - }); - return controls; - } + // Update the local settings + this.userSettings = { ...this.userSettings, ...newSettings }; + this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; + this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; - getLayerName(layer) { - const controlLayers = { - Points: this.markersLayer, - Routes: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - Areas: this.areasLayer, - }; + // Store current layer states + const layerStates = { + Points: this.map.hasLayer(this.markersLayer), + Routes: this.map.hasLayer(this.polylinesLayer), + Heatmap: this.map.hasLayer(this.heatmapLayer), + "Fog of War": this.map.hasLayer(this.fogOverlay), + "Scratch map": this.map.hasLayer(this.scratchLayer), + Areas: this.map.hasLayer(this.areasLayer), + Photos: this.map.hasLayer(this.photoMarkers) + }; - for (const [name, val] of Object.entries(controlLayers)) { - if (val && val.hasLayer && layer && val.hasLayer(layer)) // Check if the group layer contains the current layer - return name; - } + // Remove only the layer control + if (this.layerControl) { + this.map.removeControl(this.layerControl); + } - // Direct instance matching - for (const [name, val] of Object.entries(controlLayers)) { - if (val === layer) return name; - } + // Create new controls layer object + const controlsLayer = { + Points: this.markersLayer || L.layerGroup(), + Routes: this.polylinesLayer || L.layerGroup(), + Heatmap: this.heatmapLayer || L.heatLayer([]), + "Fog of War": new this.fogOverlay(), + "Scratch map": this.scratchLayer || L.layerGroup(), + Areas: this.areasLayer || L.layerGroup(), + Photos: this.photoMarkers || L.layerGroup() + }; - return undefined; // Indicate no matching layer name found - } + // Re-add the layer control in the same position + this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - applyLayerControlStates(states) { - console.log('Applying layer states:', states); - - const layerControl = { - Points: this.markersLayer, - Routes: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - Areas: this.areasLayer, - }; - - for (const [name, isVisible] of Object.entries(states)) { - const layer = layerControl[name]; - console.log(`Processing layer ${name}:`, { layer, isVisible }); - - if (layer) { - if (isVisible && !this.map.hasLayer(layer)) { - console.log(`Adding layer ${name} to map`); - this.map.addLayer(layer); - } else if (!isVisible && this.map.hasLayer(layer)) { - console.log(`Removing layer ${name} from map`); + // Restore layer visibility states + Object.entries(layerStates).forEach(([name, wasVisible]) => { + const layer = controlsLayer[name]; + if (wasVisible && layer) { + layer.addTo(this.map); + } else if (layer && this.map.hasLayer(layer)) { this.map.removeLayer(layer); } - } - } + }); - // Ensure the layer control reflects the current state - this.map.removeControl(this.layerControl); - this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map); + } catch (error) { + console.error('Error updating map settings:', error); + console.error(error.stack); + } finally { + // Remove loading indicator + setTimeout(() => { + document.body.removeChild(loadingDiv); + }, 500); + } } createPhotoMarker(photo) { diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index 482a161e..8e910274 100644 --- a/app/javascript/maps/fog_of_war.js +++ b/app/javascript/maps/fog_of_war.js @@ -25,7 +25,8 @@ export function initializeFogCanvas(map) { export function drawFogCanvas(map, markers, clearFogRadius) { const fog = document.getElementById('fog'); - if (!fog) return; + // Return early if fog element doesn't exist or isn't a canvas + if (!fog || !(fog instanceof HTMLCanvasElement)) return; const ctx = fog.getContext('2d'); if (!ctx) return; @@ -83,12 +84,25 @@ export function createFogOverlay() { return L.Layer.extend({ onAdd: (map) => { initializeFogCanvas(map); + + // Add drag event handlers to update fog during marker movement + map.on('drag', () => { + const fog = document.getElementById('fog'); + if (fog) { + // Update fog canvas position to match map position + const mapPos = map.getContainer().getBoundingClientRect(); + fog.style.left = `${mapPos.left}px`; + fog.style.top = `${mapPos.top}px`; + } + }); }, onRemove: (map) => { const fog = document.getElementById('fog'); if (fog) { fog.remove(); } + // Clean up event listener + map.off('drag'); } }); } From 9970d6313401e0ab467c86b66b1223d2e72edc93 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 11:46:57 +0100 Subject: [PATCH 011/157] Return scale and stats controls to map --- CHANGELOG.md | 10 ++++++- app/javascript/controllers/maps_controller.js | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a34d9ea6..be1d0faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,21 @@ 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.22.4 - 2025-01-15 +# 0.22.4 - 2025-01-20 + +### Added + +- You can now drag-n-drop a point on the map to update its position. Enable the "Points" layer on the map to see the points. ### Changed - Run seeds even in prod env so Unraid users could have default user. - Precompile assets in production env using dummy secret key base. +### Fixed + +- Fixed a bug where route wasn't highlighted when it was hovered or clicked. + # 0.22.3 - 2025-01-14 ### Changed diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 0fd4502f..313b477d 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -61,6 +61,35 @@ export default class extends Controller { this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14); + // Add scale control + L.control.scale({ + position: 'bottomright', + imperial: this.distanceUnit === 'mi', + metric: this.distanceUnit === 'km', + maxWidth: 120 + }).addTo(this.map) + + // Add stats control + const StatsControl = L.Control.extend({ + options: { + position: 'bottomright' + }, + onAdd: (map) => { + const div = L.DomUtil.create('div', 'leaflet-control-stats'); + const distance = this.element.dataset.distance || '0'; + const pointsNumber = this.element.dataset.points_number || '0'; + const unit = this.distanceUnit === 'mi' ? 'mi' : 'km'; + div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`; + div.style.backgroundColor = 'white'; + div.style.padding = '0 5px'; + div.style.marginRight = '5px'; + div.style.display = 'inline-block'; + return div; + } + }); + + new StatsControl().addTo(this.map); + // Set the maximum bounds to prevent infinite scroll var southWest = L.latLng(-120, -210); var northEast = L.latLng(120, 210); From 46a30dc6a27561b3b296c7e2c093a147d3e29784 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 11:52:48 +0100 Subject: [PATCH 012/157] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1d0faa..ecc13f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - You can now drag-n-drop a point on the map to update its position. Enable the "Points" layer on the map to see the points. +- `PATCH /api/v1/points/:id` endpoint added to update a point. It only accepts `latitude` and `longitude` params. #51 #503 ### Changed From 41bb2e07fb05ee62b9f0557ac349264a4b246586 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 15:17:56 +0100 Subject: [PATCH 013/157] Add user endpoint --- app/controllers/api/v1/users_controller.rb | 7 +++ config/routes.rb | 1 + spec/requests/api/v1/users_spec.rb | 18 ++++++ spec/swagger/api/v1/users_controller_spec.rb | 64 ++++++++++++++++++++ swagger/v1/swagger.yaml | 17 ++++++ 5 files changed, 107 insertions(+) create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 spec/requests/api/v1/users_spec.rb create mode 100644 spec/swagger/api/v1/users_controller_spec.rb diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 00000000..4fbb3f60 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Api::V1::UsersController < ApiController + def me + render json: { user: current_api_user } + end +end diff --git a/config/routes.rb b/config/routes.rb index 0befcca4..862ae6c4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,6 +65,7 @@ Rails.application.routes.draw do get 'health', to: 'health#index' patch 'settings', to: 'settings#update' get 'settings', to: 'settings#index' + get 'users/me', to: 'users#me' resources :areas, only: %i[index create update destroy] resources :points, only: %i[index destroy update] diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 00000000..3075a94f --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Users', type: :request do + describe 'GET /me' do + let(:user) { create(:user) } + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + + it 'returns http success' do + get '/api/v1/users/me', headers: headers + + expect(response).to have_http_status(:success) + expect(response.body).to include(user.email) + expect(response.body).to include(user.id.to_s) + end + end +end diff --git a/spec/swagger/api/v1/users_controller_spec.rb b/spec/swagger/api/v1/users_controller_spec.rb new file mode 100644 index 00000000..753f4f08 --- /dev/null +++ b/spec/swagger/api/v1/users_controller_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +describe 'Users API', type: :request do + path '/api/v1/users/me' do + get 'Returns the current user' do + tags 'Users' + consumes 'application/json' + security [bearer_auth: []] + parameter name: 'Authorization', in: :header, type: :string, required: true, + description: 'Bearer token in the format: Bearer {api_key}' + + response '200', 'user found' do + let(:user) { create(:user) } + let(:Authorization) { "Bearer #{user.api_key}" } + + schema type: :object, + properties: { + user: { + type: :object, + properties: { + id: { type: :integer }, + email: { type: :string }, + created_at: { type: :string, format: 'date-time' }, + updated_at: { type: :string, format: 'date-time' }, + api_key: { type: :string }, + theme: { type: :string }, + settings: { + type: :object, + properties: { + immich_url: { type: :string }, + route_opacity: { type: :string }, + immich_api_key: { type: :string }, + live_map_enabled: { type: :boolean }, + fog_of_war_meters: { type: :string }, + preferred_map_layer: { type: :string }, + speed_colored_routes: { type: :boolean }, + meters_between_routes: { type: :string }, + points_rendering_mode: { type: :string }, + minutes_between_routes: { type: :string }, + time_threshold_minutes: { type: :string }, + merge_threshold_minutes: { type: :string }, + speed_colored_polylines: { type: :boolean } + } + }, + admin: { type: :boolean } + } + } + } + + after do |example| + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body) + } + } + end + + run_test! + end + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index beed0840..3093df05 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -892,6 +892,23 @@ paths: - totalCountriesVisited - totalCitiesVisited - yearlyStats + "/api/v1/users/me": + get: + summary: Returns the current user + tags: + - Users + security: + - bearer_auth: [] + parameters: + - name: Authorization + in: header + required: true + description: 'Bearer token in the format: Bearer {api_key}' + schema: + type: string + responses: + '200': + description: user found servers: - url: http://{defaultHost} variables: From bb600230a7917bade9b93f712dddc9c9faf0ac0e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 15:18:40 +0100 Subject: [PATCH 014/157] Update changelog and app version --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.app_version b/.app_version index 4240544f..03035cdd 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.22.4 +0.22.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc13f47..35abea81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.22.5 - 2025-01-20 + +### Added + +- `GET /api/v1/users/me` endpoint added to get current user. + # 0.22.4 - 2025-01-20 ### Added From bb139624f7a0bc63419ee0c3972ee6b383c2e2d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:56:47 +0000 Subject: [PATCH 015/157] Bump rubocop-rails from 2.28.0 to 2.29.0 Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.28.0 to 2.29.0. - [Release notes](https://github.com/rubocop/rubocop-rails/releases) - [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.28.0...v2.29.0) --- updated-dependencies: - dependency-name: rubocop-rails dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 128bf38f..5d0e389d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,7 +95,7 @@ GEM byebug (11.1.3) chartkick (5.1.2) coderay (1.1.3) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) content_disposition (1.0.0) crack (1.0.0) @@ -154,7 +154,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) importmap-rails (2.1.0) actionpack (>= 6.0.0) @@ -232,7 +232,7 @@ GEM orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc patience_diff (1.2.0) @@ -340,7 +340,7 @@ GEM rswag-ui (2.16.0) actionpack (>= 5.2, < 8.1) railties (>= 5.2, < 8.1) - rubocop (1.69.2) + rubocop (1.70.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -352,7 +352,7 @@ GEM unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.37.0) parser (>= 3.3.1.0) - rubocop-rails (2.28.0) + rubocop-rails (2.29.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) @@ -413,7 +413,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode (0.4.4.5) - unicode-display_width (3.1.3) + unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uri (1.0.2) From f558e3d4b16f57f308a6abdc7618667c7b7c8613 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:57:28 +0000 Subject: [PATCH 016/157] Bump tailwindcss-rails from 3.2.0 to 3.3.0 Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.2.0 to 3.3.0. - [Release notes](https://github.com/rails/tailwindcss-rails/releases) - [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md) - [Commits](https://github.com/rails/tailwindcss-rails/compare/v3.2.0...v3.3.0) --- updated-dependencies: - dependency-name: tailwindcss-rails dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 128bf38f..3b1d7f62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,7 +95,7 @@ GEM byebug (11.1.3) chartkick (5.1.2) coderay (1.1.3) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) content_disposition (1.0.0) crack (1.0.0) @@ -154,7 +154,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) importmap-rails (2.1.0) actionpack (>= 6.0.0) @@ -212,18 +212,18 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.1) + nokogiri (1.18.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.1-aarch64-linux-gnu) + nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm-linux-gnu) + nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm64-darwin) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-darwin) + nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-gnu) + nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) oj (3.16.9) bigdecimal (>= 3.0) @@ -248,7 +248,7 @@ GEM pry (>= 0.13, < 0.15) pry-rails (0.3.11) pry (>= 0.13.0) - psych (5.2.2) + psych (5.2.3) date stringio public_suffix (6.0.1) @@ -297,7 +297,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.10.0) + rdoc (6.11.0) psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -396,7 +396,7 @@ GEM attr_extras (>= 6.2.4) diff-lcs patience_diff - tailwindcss-rails (3.2.0) + tailwindcss-rails (3.3.0) railties (>= 7.0.0) tailwindcss-ruby tailwindcss-ruby (3.4.17) From d549a31a0a7f8042a83d3f9de29e48a66fd5ec71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:57:39 +0000 Subject: [PATCH 017/157] Bump sidekiq-cron from 2.0.1 to 2.1.0 Bumps [sidekiq-cron](https://github.com/ondrejbartas/sidekiq-cron) from 2.0.1 to 2.1.0. - [Release notes](https://github.com/ondrejbartas/sidekiq-cron/releases) - [Changelog](https://github.com/sidekiq-cron/sidekiq-cron/blob/master/CHANGELOG.md) - [Commits](https://github.com/ondrejbartas/sidekiq-cron/compare/v2.0.1...v2.1.0) --- updated-dependencies: - dependency-name: sidekiq-cron dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 128bf38f..3f00534f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,7 +95,7 @@ GEM byebug (11.1.3) chartkick (5.1.2) coderay (1.1.3) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) content_disposition (1.0.0) crack (1.0.0) @@ -154,7 +154,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) importmap-rails (2.1.0) actionpack (>= 6.0.0) @@ -301,7 +301,7 @@ GEM psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) - redis-client (0.23.0) + redis-client (0.23.2) connection_pool regexp_parser (2.10.0) reline (0.6.0) @@ -369,7 +369,7 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (2.0.1) + sidekiq-cron (2.1.0) cronex (>= 0.13.0) fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) From 6c0a954e8ec172338db89cba17118c8f03f117f5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 16:37:15 +0100 Subject: [PATCH 018/157] Implement dawarich points parsing --- app/jobs/points/create_job.rb | 26 ++++ app/services/points/params.rb | 41 ++++++ ...dd_course_and_course_accuracy_to_points.rb | 8 ++ ...0152540_add_external_track_id_to_points.rb | 11 ++ db/schema.rb | 6 +- .../files/points/geojson_example.json | 136 ++++++++++++++++++ spec/jobs/points/create_job_spec.rb | 5 + spec/services/points/params_spec.rb | 66 +++++++++ 8 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 app/jobs/points/create_job.rb create mode 100644 app/services/points/params.rb create mode 100644 db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb create mode 100644 db/migrate/20250120152540_add_external_track_id_to_points.rb create mode 100644 spec/fixtures/files/points/geojson_example.json create mode 100644 spec/jobs/points/create_job_spec.rb create mode 100644 spec/services/points/params_spec.rb diff --git a/app/jobs/points/create_job.rb b/app/jobs/points/create_job.rb new file mode 100644 index 00000000..f079046d --- /dev/null +++ b/app/jobs/points/create_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Points::CreateJob < ApplicationJob + queue_as :default + + def perform(params, user_id) + data = Overland::Params.new(params).call + + data.each do |location| + next if point_exists?(location, user_id) + + Point.create!(location.merge(user_id:)) + end + end + + private + + def point_exists?(params, user_id) + Point.exists?( + latitude: params[:latitude], + longitude: params[:longitude], + timestamp: params[:timestamp], + user_id: + ) + end +end diff --git a/app/services/points/params.rb b/app/services/points/params.rb new file mode 100644 index 00000000..1e2873ca --- /dev/null +++ b/app/services/points/params.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Points::Params + attr_reader :data, :points + + def initialize(json) + @data = json.with_indifferent_access + @points = @data[:locations] + end + + def call + points.map do |point| + next if point[:geometry].nil? || point.dig(:properties, :timestamp).nil? + + { + latitude: point[:geometry][:coordinates][1], + longitude: point[:geometry][:coordinates][0], + battery_status: point[:properties][:battery_state], + battery: battery_level(point[:properties][:battery_level]), + timestamp: DateTime.parse(point[:properties][:timestamp]), + altitude: point[:properties][:altitude], + tracker_id: point[:properties][:device_id], + velocity: point[:properties][:speed], + ssid: point[:properties][:wifi], + accuracy: point[:properties][:horizontal_accuracy], + vertical_accuracy: point[:properties][:vertical_accuracy], + course_accuracy: point[:properties][:course_accuracy], + course: point[:properties][:course], + raw_data: point + } + end.compact + end + + private + + def battery_level(level) + value = (level.to_f * 100).to_i + + value.positive? ? value : nil + end +end diff --git a/db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb b/db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb new file mode 100644 index 00000000..78e1feb0 --- /dev/null +++ b/db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddCourseAndCourseAccuracyToPoints < ActiveRecord::Migration[8.0] + def change + add_column :points, :course, :decimal, precision: 8, scale: 5 + add_column :points, :course_accuracy, :decimal, precision: 8, scale: 5 + end +end diff --git a/db/migrate/20250120152540_add_external_track_id_to_points.rb b/db/migrate/20250120152540_add_external_track_id_to_points.rb new file mode 100644 index 00000000..4531b19d --- /dev/null +++ b/db/migrate/20250120152540_add_external_track_id_to_points.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddExternalTrackIdToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :points, :external_track_id, :string + + add_index :points, :external_track_id, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 16db4226..31ce24e6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do +ActiveRecord::Schema[8.0].define(version: 2025_01_20_152540) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -156,12 +156,16 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do t.jsonb "geodata", default: {}, null: false t.bigint "visit_id" t.datetime "reverse_geocoded_at" + t.decimal "course", precision: 8, scale: 5 + t.decimal "course_accuracy", precision: 8, scale: 5 + t.string "external_track_id" t.index ["altitude"], name: "index_points_on_altitude" t.index ["battery"], name: "index_points_on_battery" t.index ["battery_status"], name: "index_points_on_battery_status" t.index ["city"], name: "index_points_on_city" t.index ["connection"], name: "index_points_on_connection" t.index ["country"], name: "index_points_on_country" + t.index ["external_track_id"], name: "index_points_on_external_track_id" t.index ["geodata"], name: "index_points_on_geodata", using: :gin t.index ["import_id"], name: "index_points_on_import_id" t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" diff --git a/spec/fixtures/files/points/geojson_example.json b/spec/fixtures/files/points/geojson_example.json new file mode 100644 index 00000000..c1cac9e4 --- /dev/null +++ b/spec/fixtures/files/points/geojson_example.json @@ -0,0 +1,136 @@ +{ + "locations" : [ + { + "type" : "Feature", + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.40530871, + 37.744304130000003 + ] + }, + "properties" : { + "horizontal_accuracy" : 5, + "track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F", + "speed_accuracy" : 0, + "vertical_accuracy" : -1, + "course_accuracy" : 0, + "altitude" : 0, + "speed" : 92.087999999999994, + "course" : 27.07, + "timestamp" : "2025-01-17T21:03:01Z", + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46" + } + }, + { + "type" : "Feature", + "properties" : { + "timestamp" : "2025-01-17T21:03:02Z", + "horizontal_accuracy" : 5, + "course" : 24.260000000000002, + "speed_accuracy" : 0, + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "vertical_accuracy" : -1, + "altitude" : 0, + "track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F", + "speed" : 92.448000000000008, + "course_accuracy" : 0 + }, + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.40518926999999, + 37.744513759999997 + ] + } + }, + { + "type" : "Feature", + "properties" : { + "altitude" : 0, + "horizontal_accuracy" : 5, + "speed" : 123.76800000000001, + "course_accuracy" : 0, + "speed_accuracy" : 0, + "course" : 309.73000000000002, + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "timestamp" : "2025-01-17T21:18:38Z", + "vertical_accuracy" : -1 + }, + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.28487643, + 37.454486080000002 + ] + } + }, + { + "type" : "Feature", + "properties" : { + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "speed_accuracy" : 0, + "course_accuracy" : 0, + "speed" : 123.3, + "horizontal_accuracy" : 5, + "course" : 309.38, + "altitude" : 0, + "timestamp" : "2025-01-17T21:18:39Z", + "vertical_accuracy" : -1 + }, + "geometry" : { + "coordinates" : [ + -122.28517332, + 37.454684899999997 + ], + "type" : "Point" + } + }, + { + "geometry" : { + "coordinates" : [ + -122.28547306, + 37.454883219999999 + ], + "type" : "Point" + }, + "properties" : { + "course_accuracy" : 0, + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "vertical_accuracy" : -1, + "course" : 309.73000000000002, + "speed_accuracy" : 0, + "timestamp" : "2025-01-17T21:18:40Z", + "horizontal_accuracy" : 5, + "speed" : 125.06400000000001, + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "altitude" : 0 + }, + "type" : "Feature" + }, + { + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.28577665, + 37.455080109999997 + ] + }, + "properties" : { + "course_accuracy" : 0, + "speed_accuracy" : 0, + "speed" : 124.05600000000001, + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "course" : 309.73000000000002, + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "altitude" : 0, + "horizontal_accuracy" : 5, + "vertical_accuracy" : -1, + "timestamp" : "2025-01-17T21:18:41Z" + }, + "type" : "Feature" + } + ] +} diff --git a/spec/jobs/points/create_job_spec.rb b/spec/jobs/points/create_job_spec.rb new file mode 100644 index 00000000..70baa6e5 --- /dev/null +++ b/spec/jobs/points/create_job_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Points::CreateJob, type: :job do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/points/params_spec.rb b/spec/services/points/params_spec.rb new file mode 100644 index 00000000..6fb3f486 --- /dev/null +++ b/spec/services/points/params_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::Params do + describe '#call' do + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:expected_json) do + { + latitude: 37.74430413, + longitude: -122.40530871, + battery_status: nil, + battery: nil, + timestamp: DateTime.parse('2025-01-17T21:03:01Z'), + altitude: 0, + tracker_id: '8D5D4197-245B-4619-A88B-2049100ADE46', + velocity: 92.088, + ssid: nil, + accuracy: 5, + vertical_accuracy: -1, + course_accuracy: 0, + course: 27.07, + raw_data: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.40530871, 37.74430413] + }, + properties: { + horizontal_accuracy: 5, + track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F', + speed_accuracy: 0, + vertical_accuracy: -1, + course_accuracy: 0, + altitude: 0, + speed: 92.088, + course: 27.07, + timestamp: '2025-01-17T21:03:01Z', + device_id: '8D5D4197-245B-4619-A88B-2049100ADE46' + } + }.with_indifferent_access + } + end + + subject(:params) { described_class.new(json).call } + + it 'returns an array of points' do + expect(params).to be_an(Array) + expect(params.first).to eq(expected_json) + end + + it 'returns the correct number of points' do + expect(params.size).to eq(6) + end + + it 'returns correct keys' do + expect(params.first.keys).to eq(expected_json.keys) + end + + it 'returns the correct values' do + expect(params.first).to eq(expected_json) + end + end +end From 6644fc9a132edc1f6b3c3173a746c25492275fbe Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 17:59:13 +0100 Subject: [PATCH 019/157] Introduce uniqueness index and validation for points --- app/controllers/api/v1/points_controller.rb | 3 ++ app/controllers/map_controller.rb | 1 - app/jobs/points/create_job.rb | 11 ++++--- app/models/point.rb | 6 +++- .../20250120154554_remove_duplicate_points.rb | 31 +++++++++++++++++++ db/data_schema.rb | 2 +- ...250120154555_add_unique_index_to_points.rb | 16 ++++++++++ db/schema.rb | 3 +- spec/factories/points.rb | 4 +++ spec/factories/trips.rb | 14 ++++++--- .../files/geojson/export_same_points.json | 2 +- spec/jobs/bulk_stats_calculating_job_spec.rb | 13 ++++++-- spec/models/import_spec.rb | 6 +++- spec/models/stat_spec.rb | 10 ++++-- spec/models/user_spec.rb | 6 +++- spec/requests/api/v1/points_spec.rb | 18 ++++++----- spec/requests/api/v1/stats_spec.rb | 12 +++++-- spec/requests/exports_spec.rb | 6 +++- spec/requests/map_spec.rb | 6 +++- spec/serializers/export_serializer_spec.rb | 7 ++++- .../points/geojson_serializer_spec.rb | 7 ++++- .../serializers/points/gpx_serializer_spec.rb | 6 +++- spec/serializers/stats_serializer_spec.rb | 10 ++++-- spec/services/exports/create_spec.rb | 7 ++++- .../google_maps/records_parser_spec.rb | 6 ++-- spec/services/jobs/create_spec.rb | 20 ++++++++++-- spec/swagger/api/v1/points_controller_spec.rb | 6 +++- spec/swagger/api/v1/stats_controller_spec.rb | 14 +++++++-- 28 files changed, 204 insertions(+), 49 deletions(-) create mode 100644 db/data/20250120154554_remove_duplicate_points.rb create mode 100644 db/migrate/20250120154555_add_unique_index_to_points.rb diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 7905ca68..016358ae 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -21,6 +21,9 @@ class Api::V1::PointsController < ApiController render json: serialized_points end + def create + end + def update point = current_api_user.tracked_points.find(params[:id]) diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index 7a7246c5..bad160d5 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -6,7 +6,6 @@ class MapController < ApplicationController def index @points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) - @countries_and_cities = CountriesAndCities.new(@points).call @coordinates = @points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country) .map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] } diff --git a/app/jobs/points/create_job.rb b/app/jobs/points/create_job.rb index f079046d..148349fe 100644 --- a/app/jobs/points/create_job.rb +++ b/app/jobs/points/create_job.rb @@ -4,12 +4,13 @@ class Points::CreateJob < ApplicationJob queue_as :default def perform(params, user_id) - data = Overland::Params.new(params).call + data = Points::Params.new(params, user_id).call - data.each do |location| - next if point_exists?(location, user_id) - - Point.create!(location.merge(user_id:)) + data.each_slice(1000) do |location_batch| + Point.upsert_all( + location_batch, + unique_by: %i[latitude longitude timestamp user_id] + ) end end diff --git a/app/models/point.rb b/app/models/point.rb index 040e6d41..f28b8043 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -8,7 +8,11 @@ class Point < ApplicationRecord belongs_to :user validates :latitude, :longitude, :timestamp, presence: true - + validates :timestamp, uniqueness: { + scope: %i[latitude longitude user_id], + message: 'already has a point at this location and time for this user', + index: true + } enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true enum :trigger, { unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3, diff --git a/db/data/20250120154554_remove_duplicate_points.rb b/db/data/20250120154554_remove_duplicate_points.rb new file mode 100644 index 00000000..2eaa2e4c --- /dev/null +++ b/db/data/20250120154554_remove_duplicate_points.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class RemoveDuplicatePoints < ActiveRecord::Migration[8.0] + def up + # Find duplicate groups using a subquery + duplicate_groups = + Point.select('latitude, longitude, timestamp, user_id, COUNT(*) as count') + .group('latitude, longitude, timestamp, user_id') + .having('COUNT(*) > 1') + + puts "Duplicate groups found: #{duplicate_groups.length}" + + duplicate_groups.each do |group| + points = Point.where( + latitude: group.latitude, + longitude: group.longitude, + timestamp: group.timestamp, + user_id: group.user_id + ).order(id: :asc) + + # Keep the latest record and destroy all others + latest = points.last + points.where.not(id: latest.id).destroy_all + end + end + + def down + # This migration cannot be reversed + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/data_schema.rb b/db/data_schema.rb index 222b8d11..56adf2dc 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 20250104204852) +DataMigrate::Data.define(version: 20250120154554) diff --git a/db/migrate/20250120154555_add_unique_index_to_points.rb b/db/migrate/20250120154555_add_unique_index_to_points.rb new file mode 100644 index 00000000..fc224ab0 --- /dev/null +++ b/db/migrate/20250120154555_add_unique_index_to_points.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + add_index :points, %i[latitude longitude timestamp user_id], + unique: true, + name: 'unique_points_index', + algorithm: :concurrently + end + + def down + remove_index :points, name: 'unique_points_index' + end +end diff --git a/db/schema.rb b/db/schema.rb index 31ce24e6..8fc8554c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_20_152540) do +ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -168,6 +168,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_20_152540) do t.index ["external_track_id"], name: "index_points_on_external_track_id" t.index ["geodata"], name: "index_points_on_geodata", using: :gin t.index ["import_id"], name: "index_points_on_import_id" + t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_index", unique: true t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at" t.index ["timestamp"], name: "index_points_on_timestamp" diff --git a/spec/factories/points.rb b/spec/factories/points.rb index 6ae12ab2..2288a07d 100644 --- a/spec/factories/points.rb +++ b/spec/factories/points.rb @@ -25,6 +25,10 @@ FactoryBot.define do import_id { '' } city { nil } country { nil } + reverse_geocoded_at { nil } + course { nil } + course_accuracy { nil } + external_track_id { nil } user trait :with_known_location do diff --git a/spec/factories/trips.rb b/spec/factories/trips.rb index 237a187b..4ef4041a 100644 --- a/spec/factories/trips.rb +++ b/spec/factories/trips.rb @@ -10,11 +10,15 @@ FactoryBot.define do trait :with_points do after(:build) do |trip| - create_list( - :point, 25, - user: trip.user, - timestamp: trip.started_at + (1..1000).to_a.sample.minutes - ) + (1..25).map do |i| + create( + :point, + :with_geodata, + :reverse_geocoded, + timestamp: trip.started_at + i.minutes, + user: trip.user + ) + end end end end diff --git a/spec/fixtures/files/geojson/export_same_points.json b/spec/fixtures/files/geojson/export_same_points.json index 7a20a47f..f3961b32 100644 --- a/spec/fixtures/files/geojson/export_same_points.json +++ b/spec/fixtures/files/geojson/export_same_points.json @@ -1 +1 @@ -{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}}]} +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459201,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459202,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459203,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459204,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459205,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459206,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459207,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459208,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459209,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}}]} diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb index 15bbc9fb..632fa47e 100644 --- a/spec/jobs/bulk_stats_calculating_job_spec.rb +++ b/spec/jobs/bulk_stats_calculating_job_spec.rb @@ -9,8 +9,17 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do let(:timestamp) { DateTime.new(2024, 1, 1).to_i } - let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) } - let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) } + let!(:points1) do + (1..10).map do |i| + create(:point, user_id: user1.id, timestamp: timestamp + i.minutes) + end + end + + let!(:points2) do + (1..10).map do |i| + create(:point, user_id: user2.id, timestamp: timestamp + i.minutes) + end + end it 'enqueues Stats::CalculatingJob for each user' do expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1) diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index d6c7efc8..8b682409 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -26,7 +26,11 @@ RSpec.describe Import, type: :model do describe '#years_and_months_tracked' do let(:import) { create(:import) } let(:timestamp) { Time.zone.local(2024, 11, 1) } - let!(:points) { create_list(:point, 3, import:, timestamp:) } + let!(:points) do + (1..3).map do |i| + create(:point, import:, timestamp: timestamp + i.minutes) + end + end it 'returns years and months tracked' do expect(import.years_and_months_tracked).to eq([[2024, 11]]) diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb index af8873b6..1208e006 100644 --- a/spec/models/stat_spec.rb +++ b/spec/models/stat_spec.rb @@ -89,8 +89,14 @@ RSpec.describe Stat, type: :model do subject { stat.points.to_a } let(:stat) { create(:stat, year:, month: 1, user:) } - let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) } - let!(:points) { create_list(:point, 3, user:, timestamp:) } + let(:base_timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) } + let!(:points) do + [ + create(:point, user:, timestamp: base_timestamp), + create(:point, user:, timestamp: base_timestamp + 1.hour), + create(:point, user:, timestamp: base_timestamp + 2.hours) + ] + end it 'returns points' do expect(subject).to eq(points) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a1059d0a..398e436f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -115,7 +115,11 @@ RSpec.describe User, type: :model do end describe '#years_tracked' do - let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) } + let!(:points) do + (1..3).map do |i| + create(:point, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0) + i.minutes) + end + end it 'returns years tracked' do expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }]) diff --git a/spec/requests/api/v1/points_spec.rb b/spec/requests/api/v1/points_spec.rb index 5120e5ce..3d5f49d8 100644 --- a/spec/requests/api/v1/points_spec.rb +++ b/spec/requests/api/v1/points_spec.rb @@ -4,7 +4,11 @@ require 'rails_helper' RSpec.describe 'Api::V1::Points', type: :request do let!(:user) { create(:user) } - let!(:points) { create_list(:point, 150, user:) } + let!(:points) do + (1..15).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end describe 'GET /index' do context 'when regular version of points is requested' do @@ -21,7 +25,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(100) + expect(json_response.size).to eq(15) end it 'returns a list of points with pagination' do @@ -31,7 +35,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(10) + expect(json_response.size).to eq(5) end it 'returns a list of points with pagination headers' do @@ -40,7 +44,7 @@ RSpec.describe 'Api::V1::Points', type: :request do expect(response).to have_http_status(:ok) expect(response.headers['X-Current-Page']).to eq('2') - expect(response.headers['X-Total-Pages']).to eq('15') + expect(response.headers['X-Total-Pages']).to eq('2') end end @@ -58,7 +62,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(100) + expect(json_response.size).to eq(15) end it 'returns a list of points with pagination' do @@ -68,7 +72,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(10) + expect(json_response.size).to eq(5) end it 'returns a list of points with pagination headers' do @@ -77,7 +81,7 @@ RSpec.describe 'Api::V1::Points', type: :request do expect(response).to have_http_status(:ok) expect(response.headers['X-Current-Page']).to eq('2') - expect(response.headers['X-Total-Pages']).to eq('15') + expect(response.headers['X-Total-Pages']).to eq('2') end it 'returns a list of points with slim attributes' do diff --git a/spec/requests/api/v1/stats_spec.rb b/spec/requests/api/v1/stats_spec.rb index d733ae3f..89cdc8e4 100644 --- a/spec/requests/api/v1/stats_spec.rb +++ b/spec/requests/api/v1/stats_spec.rb @@ -10,14 +10,20 @@ RSpec.describe 'Api::V1::Stats', type: :request do let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } let!(:points_in_2020) do - create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:) + (1..85).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:) + end + end + let!(:points_in_2021) do + (1..95).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:) + end end - let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) } let(:expected_json) do { totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum, totalPointsTracked: points_in_2020.count + points_in_2021.count, - totalReverseGeocodedPoints: points_in_2020.count, + totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count, totalCountriesVisited: 1, totalCitiesVisited: 1, yearlyStats: [ diff --git a/spec/requests/exports_spec.rb b/spec/requests/exports_spec.rb index c96ac744..0ec6fa61 100644 --- a/spec/requests/exports_spec.rb +++ b/spec/requests/exports_spec.rb @@ -37,7 +37,11 @@ RSpec.describe '/exports', type: :request do before { sign_in user } context 'with valid parameters' do - let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) } + let(:points) do + (1..10).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end it 'creates a new Export' do expect { post exports_url, params: }.to change(Export, :count).by(1) diff --git a/spec/requests/map_spec.rb b/spec/requests/map_spec.rb index 3cda64a5..700a214a 100644 --- a/spec/requests/map_spec.rb +++ b/spec/requests/map_spec.rb @@ -11,7 +11,11 @@ RSpec.describe 'Map', type: :request do describe 'GET /index' do context 'when user signed in' do let(:user) { create(:user) } - let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) } + let(:points) do + (1..10).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end before { sign_in user } diff --git a/spec/serializers/export_serializer_spec.rb b/spec/serializers/export_serializer_spec.rb index e77acff5..353d53fb 100644 --- a/spec/serializers/export_serializer_spec.rb +++ b/spec/serializers/export_serializer_spec.rb @@ -7,7 +7,12 @@ RSpec.describe ExportSerializer do subject(:serializer) { described_class.new(points, user_email).call } let(:user_email) { 'ab@cd.com' } - let(:points) { create_list(:point, 2) } + let(:points) do + (1..2).map do |i| + create(:point, timestamp: 1.day.ago + i.minutes) + end + end + let(:expected_json) do { user_email => { diff --git a/spec/serializers/points/geojson_serializer_spec.rb b/spec/serializers/points/geojson_serializer_spec.rb index a532a192..e125c7b3 100644 --- a/spec/serializers/points/geojson_serializer_spec.rb +++ b/spec/serializers/points/geojson_serializer_spec.rb @@ -6,7 +6,12 @@ RSpec.describe Points::GeojsonSerializer do describe '#call' do subject(:serializer) { described_class.new(points).call } - let(:points) { create_list(:point, 3) } + let(:points) do + (1..3).map do |i| + create(:point, timestamp: 1.day.ago + i.minutes) + end + end + let(:expected_json) do { type: 'FeatureCollection', diff --git a/spec/serializers/points/gpx_serializer_spec.rb b/spec/serializers/points/gpx_serializer_spec.rb index e2b108b9..1434ca5d 100644 --- a/spec/serializers/points/gpx_serializer_spec.rb +++ b/spec/serializers/points/gpx_serializer_spec.rb @@ -6,7 +6,11 @@ RSpec.describe Points::GpxSerializer do describe '#call' do subject(:serializer) { described_class.new(points, 'some_name').call } - let(:points) { create_list(:point, 3) } + let(:points) do + (1..3).map do |i| + create(:point, timestamp: 1.day.ago + i.minutes) + end + end it 'returns GPX file' do expect(serializer).to be_a(GPX::GPXFile) diff --git a/spec/serializers/stats_serializer_spec.rb b/spec/serializers/stats_serializer_spec.rb index ad6f5bc8..2fba6656 100644 --- a/spec/serializers/stats_serializer_spec.rb +++ b/spec/serializers/stats_serializer_spec.rb @@ -29,16 +29,20 @@ RSpec.describe StatsSerializer do let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } let!(:points_in_2020) do - create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:) + (1..85).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:) + end end let!(:points_in_2021) do - create_list(:point, 95, timestamp: Time.zone.local(2021), user:) + (1..95).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:) + end end let(:expected_json) do { "totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum, "totalPointsTracked": points_in_2020.count + points_in_2021.count, - "totalReverseGeocodedPoints": points_in_2020.count, + "totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count, "totalCountriesVisited": 1, "totalCitiesVisited": 1, "yearlyStats": [ diff --git a/spec/services/exports/create_spec.rb b/spec/services/exports/create_spec.rb index 2110b6b0..1bea40d2 100644 --- a/spec/services/exports/create_spec.rb +++ b/spec/services/exports/create_spec.rb @@ -15,7 +15,12 @@ RSpec.describe Exports::Create do let(:export_content) { Points::GeojsonSerializer.new(points).call } let(:reverse_geocoded_at) { Time.zone.local(2021, 1, 1) } let!(:points) do - create_list(:point, 10, :with_known_location, user:, timestamp: start_at.to_datetime.to_i, reverse_geocoded_at:) + 10.times.map do |i| + create(:point, :with_known_location, + user: user, + timestamp: start_at.to_datetime.to_i + i, + reverse_geocoded_at: reverse_geocoded_at) + end end before do diff --git a/spec/services/google_maps/records_parser_spec.rb b/spec/services/google_maps/records_parser_spec.rb index 44ec23b6..96495dad 100644 --- a/spec/services/google_maps/records_parser_spec.rb +++ b/spec/services/google_maps/records_parser_spec.rb @@ -7,7 +7,7 @@ RSpec.describe GoogleMaps::RecordsParser do subject(:parser) { described_class.new(import).call(json) } let(:import) { create(:import) } - let(:time) { Time.zone.now } + let(:time) { DateTime.new(2025, 1, 1, 12, 0, 0) } let(:json) do { 'latitudeE7' => 123_456_789, @@ -31,7 +31,7 @@ RSpec.describe GoogleMaps::RecordsParser do before do create( :point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789, - timestamp: Time.zone.now.to_i + timestamp: time.to_i ) end @@ -78,4 +78,4 @@ RSpec.describe GoogleMaps::RecordsParser do end end end -end \ No newline at end of file +end diff --git a/spec/services/jobs/create_spec.rb b/spec/services/jobs/create_spec.rb index cc482b67..84988ff3 100644 --- a/spec/services/jobs/create_spec.rb +++ b/spec/services/jobs/create_spec.rb @@ -8,7 +8,12 @@ RSpec.describe Jobs::Create do context 'when job_name is start_reverse_geocoding' do let(:user) { create(:user) } - let(:points) { create_list(:point, 4, user:) } + let(:points) do + (1..4).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end + let(:job_name) { 'start_reverse_geocoding' } it 'enqueues reverse geocoding for all user points' do @@ -24,8 +29,17 @@ RSpec.describe Jobs::Create do context 'when job_name is continue_reverse_geocoding' do let(:user) { create(:user) } - let(:points_without_address) { create_list(:point, 4, user:, country: nil, city: nil) } - let(:points_with_address) { create_list(:point, 5, user:, country: 'Country', city: 'City') } + let(:points_without_address) do + (1..4).map do |i| + create(:point, user:, country: nil, city: nil, timestamp: 1.day.ago + i.minutes) + end + end + + let(:points_with_address) do + (1..5).map do |i| + create(:point, user:, country: 'Country', city: 'City', timestamp: 1.day.ago + i.minutes) + end + end let(:job_name) { 'continue_reverse_geocoding' } diff --git a/spec/swagger/api/v1/points_controller_spec.rb b/spec/swagger/api/v1/points_controller_spec.rb index cbc31e6d..d4ff924c 100644 --- a/spec/swagger/api/v1/points_controller_spec.rb +++ b/spec/swagger/api/v1/points_controller_spec.rb @@ -58,7 +58,11 @@ describe 'Points API', type: :request do let(:api_key) { user.api_key } let(:start_at) { Time.zone.now - 1.day } let(:end_at) { Time.zone.now } - let(:points) { create_list(:point, 10, user:, timestamp: 2.hours.ago) } + let(:points) do + (1..10).map do |i| + create(:point, user:, timestamp: 2.hours.ago + i.minutes) + end + end run_test! end diff --git a/spec/swagger/api/v1/stats_controller_spec.rb b/spec/swagger/api/v1/stats_controller_spec.rb index a6a49c0f..b1fda703 100644 --- a/spec/swagger/api/v1/stats_controller_spec.rb +++ b/spec/swagger/api/v1/stats_controller_spec.rb @@ -57,8 +57,18 @@ describe 'Stats API', type: :request do let!(:user) { create(:user) } let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } - let!(:points_in_2020) { create_list(:point, 85, :with_geodata, timestamp: Time.zone.local(2020), user:) } - let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) } + let!(:points_in_2020) do + (1..85).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, +user:) + end + end + let!(:points_in_2021) do + (1..95).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, +user:) + end + end let(:api_key) { user.api_key } run_test! From 983768a5726fc134ae7cbe21feb1a68909b8d4a4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 20:07:46 +0100 Subject: [PATCH 020/157] Assign user_id to points on parsing --- app/controllers/api/v1/points_controller.rb | 7 +++++++ app/services/points/params.rb | 16 ++++++++++++---- spec/jobs/points/create_job_spec.rb | 15 ++++++++++++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 016358ae..f09340b8 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -22,6 +22,9 @@ class Api::V1::PointsController < ApiController end def create + Points::CreateJob.perform_later(batch_params, current_api_user.id) + + render json: { message: 'Points are being processed' } end def update @@ -45,6 +48,10 @@ class Api::V1::PointsController < ApiController params.require(:point).permit(:latitude, :longitude) end + def batch_params + params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {}) + end + def point_serializer params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer end diff --git a/app/services/points/params.rb b/app/services/points/params.rb index 1e2873ca..8c1b8a51 100644 --- a/app/services/points/params.rb +++ b/app/services/points/params.rb @@ -1,16 +1,17 @@ # frozen_string_literal: true class Points::Params - attr_reader :data, :points + attr_reader :data, :points, :user_id - def initialize(json) + def initialize(json, user_id) @data = json.with_indifferent_access @points = @data[:locations] + @user_id = user_id end def call points.map do |point| - next if point[:geometry].nil? || point.dig(:properties, :timestamp).nil? + next unless params_valid?(point) { latitude: point[:geometry][:coordinates][1], @@ -26,7 +27,8 @@ class Points::Params vertical_accuracy: point[:properties][:vertical_accuracy], course_accuracy: point[:properties][:course_accuracy], course: point[:properties][:course], - raw_data: point + raw_data: point, + user_id: user_id } end.compact end @@ -38,4 +40,10 @@ class Points::Params value.positive? ? value : nil end + + def params_valid?(point) + point[:geometry].present? && + point[:geometry][:coordinates].present? && + point.dig(:properties, :timestamp).present? + end end diff --git a/spec/jobs/points/create_job_spec.rb b/spec/jobs/points/create_job_spec.rb index 70baa6e5..7fa14b15 100644 --- a/spec/jobs/points/create_job_spec.rb +++ b/spec/jobs/points/create_job_spec.rb @@ -1,5 +1,18 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Points::CreateJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" + describe '#perform' do + subject(:perform) { described_class.new.perform(json, user.id) } + + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:user) { create(:user) } + + it 'creates a point' do + expect { perform }.to change { Point.count }.by(6) + end + end end From 112f13587ce986b5e1a366277bb61972a8070b61 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 20:17:27 +0100 Subject: [PATCH 021/157] Add swagger docs for POST /api/v1/points --- .app_version | 2 +- CHANGELOG.md | 6 ++ config/routes.rb | 2 +- spec/swagger/api/v1/points_controller_spec.rb | 81 +++++++++++++++++++ swagger/v1/swagger.yaml | 81 +++++++++++++++++++ 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 4240544f..03035cdd 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.22.4 +0.22.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc13f47..f2622d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.22.5 - 2025-01-20 + +### Added + +- `POST /api/v1/points/create` endpoint added to create points from a file. + # 0.22.4 - 2025-01-20 ### Added diff --git a/config/routes.rb b/config/routes.rb index 0befcca4..9e1384a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,7 +67,7 @@ Rails.application.routes.draw do get 'settings', to: 'settings#index' resources :areas, only: %i[index create update destroy] - resources :points, only: %i[index destroy update] + resources :points, only: %i[index create update destroy] resources :visits, only: %i[update] resources :stats, only: :index diff --git a/spec/swagger/api/v1/points_controller_spec.rb b/spec/swagger/api/v1/points_controller_spec.rb index d4ff924c..d3dc087c 100644 --- a/spec/swagger/api/v1/points_controller_spec.rb +++ b/spec/swagger/api/v1/points_controller_spec.rb @@ -67,6 +67,87 @@ describe 'Points API', type: :request do run_test! end end + + post 'Creates a batch of points' do + request_body_example value: { + locations: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.40530871, 37.74430413] + }, + properties: { + timestamp: '2025-01-17T21:03:01Z', + horizontal_accuracy: 5, + vertical_accuracy: -1, + altitude: 0, + speed: 92.088, + speed_accuracy: 0, + course: 27.07, + course_accuracy: 0, + track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F', + device_id: '8D5D4197-245B-4619-A88B-2049100ADE46' + } + } + ] + } + tags 'Batches' + consumes 'application/json' + parameter name: :locations, in: :body, schema: { + type: :object, + properties: { + type: { type: :string }, + geometry: { + type: :object, + properties: { + type: { type: :string }, + coordinates: { type: :array, items: { type: :number } } + } + }, + properties: { + type: :object, + properties: { + timestamp: { type: :string }, + horizontal_accuracy: { type: :number }, + vertical_accuracy: { type: :number }, + altitude: { type: :number }, + speed: { type: :number }, + speed_accuracy: { type: :number }, + course: { type: :number }, + course_accuracy: { type: :number }, + track_id: { type: :string }, + device_id: { type: :string } + } + } + }, + required: %w[geometry properties] + } + + parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key' + + response '200', 'Batch of points being processed' do + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:params) { json } + let(:locations) { params['locations'] } + let(:api_key) { create(:user).api_key } + + run_test! + end + + response '401', 'Unauthorized' do + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:params) { json } + let(:locations) { params['locations'] } + let(:api_key) { 'invalid_api_key' } + + run_test! + end + end end path '/api/v1/points/{id}' do diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index beed0840..d40786d2 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -696,6 +696,87 @@ paths: type: string visit_id: type: string + post: + summary: Creates a batch of points + tags: + - Batches + parameters: + - name: api_key + in: query + required: true + description: API Key + schema: + type: string + responses: + '200': + description: Batch of points being processed + '401': + description: Unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + geometry: + type: object + properties: + type: + type: string + coordinates: + type: array + items: + type: number + properties: + type: object + properties: + timestamp: + type: string + horizontal_accuracy: + type: number + vertical_accuracy: + type: number + altitude: + type: number + speed: + type: number + speed_accuracy: + type: number + course: + type: number + course_accuracy: + type: number + track_id: + type: string + device_id: + type: string + required: + - geometry + - properties + examples: + '0': + summary: Creates a batch of points + value: + locations: + - type: Feature + geometry: + type: Point + coordinates: + - -122.40530871 + - 37.74430413 + properties: + timestamp: '2025-01-17T21:03:01Z' + horizontal_accuracy: 5 + vertical_accuracy: -1 + altitude: 0 + speed: 92.088 + speed_accuracy: 0 + course: 27.07 + course_accuracy: 0 + track_id: 799F32F5-89BB-45FB-A639-098B1B95B09F + device_id: 8D5D4197-245B-4619-A88B-2049100ADE46 "/api/v1/points/{id}": delete: summary: Deletes a point From 4e49e67fe521d4e6b1f747385b31e224ba0ed8cb Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 20:22:47 +0100 Subject: [PATCH 022/157] Update changelog and app version --- .app_version | 2 +- CHANGELOG.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 03035cdd..ca222b7c 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.22.5 +0.23.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index f2622d79..332291f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,16 @@ 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.22.5 - 2025-01-20 +# 0.23.0 - 2025-01-20 + +⚠️ IMPORTANT ⚠️ + +This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a backup before updating to this version. ### Added - `POST /api/v1/points/create` endpoint added to create points from a file. +- An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations. # 0.22.4 - 2025-01-20 From 85e38cae21784bbb5a04caa71d40510a1f71e1a4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 20:23:57 +0100 Subject: [PATCH 023/157] Provide link to a back up instructions --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 332291f1..f8566e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # 0.23.0 - 2025-01-20 -⚠️ IMPORTANT ⚠️ +## ⚠️ IMPORTANT ⚠️ -This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a backup before updating to this version. +This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a [backup](https://dawarich.app/docs/tutorials/backup-and-restore) before updating to this version. ### Added From a311325c828635a76ae1b53d02106b1446864f56 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 20:41:26 +0100 Subject: [PATCH 024/157] Fix failed tests --- app/jobs/points/create_job.rb | 14 ++------------ spec/services/points/params_spec.rb | 6 ++++-- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/app/jobs/points/create_job.rb b/app/jobs/points/create_job.rb index 148349fe..964c50f7 100644 --- a/app/jobs/points/create_job.rb +++ b/app/jobs/points/create_job.rb @@ -9,19 +9,9 @@ class Points::CreateJob < ApplicationJob data.each_slice(1000) do |location_batch| Point.upsert_all( location_batch, - unique_by: %i[latitude longitude timestamp user_id] + unique_by: %i[latitude longitude timestamp user_id], + returning: false ) end end - - private - - def point_exists?(params, user_id) - Point.exists?( - latitude: params[:latitude], - longitude: params[:longitude], - timestamp: params[:timestamp], - user_id: - ) - end end diff --git a/spec/services/points/params_spec.rb b/spec/services/points/params_spec.rb index 6fb3f486..62f9b82b 100644 --- a/spec/services/points/params_spec.rb +++ b/spec/services/points/params_spec.rb @@ -4,6 +4,7 @@ require 'rails_helper' RSpec.describe Points::Params do describe '#call' do + let(:user) { create(:user) } let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } let(:file) { File.open(file_path) } let(:json) { JSON.parse(file.read) } @@ -40,11 +41,12 @@ RSpec.describe Points::Params do timestamp: '2025-01-17T21:03:01Z', device_id: '8D5D4197-245B-4619-A88B-2049100ADE46' } - }.with_indifferent_access + }.with_indifferent_access, + user_id: user.id } end - subject(:params) { described_class.new(json).call } + subject(:params) { described_class.new(json, user.id).call } it 'returns an array of points' do expect(params).to be_an(Array) From 1d820462f6c8667726803d9e1ba05dff4862ff87 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 20 Jan 2025 20:42:15 +0100 Subject: [PATCH 025/157] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b394bef0..2f4efd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ This release includes a data migration to remove duplicated points from the data ### Added -- `POST /api/v1/points/create` endpoint added to create points from a file. +- `POST /api/v1/points/create` endpoint added. - An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations. - `GET /api/v1/users/me` endpoint added to get current user. From 3fe48f66853abbc09cf172c311a1a43d7e65bea8 Mon Sep 17 00:00:00 2001 From: Aleksei Besogonov Date: Wed, 8 Jan 2025 19:32:29 -0800 Subject: [PATCH 026/157] Add support for Nominatim This adds support for Nominatim-based reverse geocoding, along with Photon and Geofi. To use it, set the environment variables: NOMINATIM_API_HOST - the host name of the OSM Nominatim server NOMINATIM_API_USE_HTTPS - use the HTTPS to connect NOMINATIM_API_KEY - the API key --- config/initializers/01_constants.rb | 4 ++++ config/initializers/03_dawarich_settings.rb | 6 +++++- config/initializers/geocoder.rb | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index ce760238..bd5cbca2 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -13,5 +13,9 @@ PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil) PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil) PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true' +NOMINATIM_API_HOST = ENV.fetch('NOMINATIM_API_HOST', nil) +NOMINATIM_API_KEY = ENV.fetch('NOMINATIM_API_KEY', nil) +NOMINATIM_API_USE_HTTPS = ENV.fetch('NOMINATIM_API_USE_HTTPS', 'true') == 'true' + GEOAPIFY_API_KEY = ENV.fetch('GEOAPIFY_API_KEY', nil) # /Reverse geocoding settings diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb index 87cf4817..34a0d831 100644 --- a/config/initializers/03_dawarich_settings.rb +++ b/config/initializers/03_dawarich_settings.rb @@ -3,7 +3,7 @@ class DawarichSettings class << self def reverse_geocoding_enabled? - @reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? + @reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled? end def photon_enabled? @@ -17,5 +17,9 @@ class DawarichSettings def geoapify_enabled? @geoapify_enabled ||= GEOAPIFY_API_KEY.present? end + + def nominatim_enabled? + @nominatim_enabled ||= NOMINATIM_API_HOST.present? + end end end diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index eb1a7fc4..46cd433d 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -19,6 +19,12 @@ if PHOTON_API_HOST.present? elsif GEOAPIFY_API_KEY.present? settings[:lookup] = :geoapify settings[:api_key] = GEOAPIFY_API_KEY +elsif NOMINATIM_API_HOST.present? + settings[:lookup] = :nominatim + settings[:nominatim] = { use_https: NOMINATIM_API_USE_HTTPS, host: NOMINATIM_API_HOST } + if NOMINATIM_API_KEY.present? + settings[:api_key] = NOMINATIM_API_KEY + end end Geocoder.configure(settings) From 049812823f6595f83d13a191f92b68a041f8e419 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 10:07:54 +0100 Subject: [PATCH 027/157] Stream google records import --- app/jobs/import/google_takeout_job.rb | 7 ++- app/services/google_maps/records_parser.rb | 45 ++++++++++------- app/services/tasks/imports/google_records.rb | 52 ++++++++++++++++---- 3 files changed, 72 insertions(+), 32 deletions(-) diff --git a/app/jobs/import/google_takeout_job.rb b/app/jobs/import/google_takeout_job.rb index d962a304..4cb74ae2 100644 --- a/app/jobs/import/google_takeout_job.rb +++ b/app/jobs/import/google_takeout_job.rb @@ -4,11 +4,10 @@ class Import::GoogleTakeoutJob < ApplicationJob queue_as :imports sidekiq_options retry: false - def perform(import_id, json_string) + def perform(import_id, json_array) import = Import.find(import_id) + records = Oj.load(json_array) - json = Oj.load(json_string) - - GoogleMaps::RecordsParser.new(import).call(json) + GoogleMaps::RecordsParser.new(import).call(records) end end diff --git a/app/services/google_maps/records_parser.rb b/app/services/google_maps/records_parser.rb index 04ee4621..e2fe2e05 100644 --- a/app/services/google_maps/records_parser.rb +++ b/app/services/google_maps/records_parser.rb @@ -1,32 +1,25 @@ # frozen_string_literal: true class GoogleMaps::RecordsParser + BATCH_SIZE = 1000 attr_reader :import def initialize(import) @import = import + @batch = [] end - def call(json) - data = parse_json(json) + def call(records) + Array(records).each do |record| + @batch << parse_json(record) - return if Point.exists?( - latitude: data[:latitude], - longitude: data[:longitude], - timestamp: data[:timestamp], - user_id: import.user_id - ) + if @batch.size >= BATCH_SIZE + bulk_insert_points + @batch = [] + end + end - Point.create( - latitude: data[:latitude], - longitude: data[:longitude], - timestamp: data[:timestamp], - raw_data: data[:raw_data], - topic: 'Google Maps Timeline Export', - tracker_id: 'google-maps-timeline-export', - import_id: import.id, - user_id: import.user_id - ) + bulk_insert_points if @batch.any? end private @@ -38,7 +31,21 @@ class GoogleMaps::RecordsParser timestamp: Timestamps.parse_timestamp(json['timestamp'] || json['timestampMs']), altitude: json['altitude'], velocity: json['velocity'], - raw_data: json + raw_data: json, + topic: 'Google Maps Timeline Export', + tracker_id: 'google-maps-timeline-export', + import_id: import.id, + user_id: import.user_id, + created_at: Time.current, + updated_at: Time.current } end + + def bulk_insert_points + Point.upsert_all( + @batch, + unique_by: %i[latitude longitude timestamp user_id], + returning: false + ) + end end diff --git a/app/services/tasks/imports/google_records.rb b/app/services/tasks/imports/google_records.rb index 8f8839e3..9b91f76f 100644 --- a/app/services/tasks/imports/google_records.rb +++ b/app/services/tasks/imports/google_records.rb @@ -4,6 +4,8 @@ # the main source of user's location history data. class Tasks::Imports::GoogleRecords + BATCH_SIZE = 1000 # Adjust based on your needs + def initialize(file_path, user_email) @file_path = file_path @user = User.find_by(email: user_email) @@ -14,10 +16,11 @@ class Tasks::Imports::GoogleRecords import_id = create_import log_start - file_content = read_file - json_data = Oj.load(file_content) - schedule_import_jobs(json_data, import_id) + process_file_in_batches(import_id) log_success + rescue Oj::ParseError => e + Rails.logger.error("JSON parsing error: #{e.message}") + raise end private @@ -26,14 +29,45 @@ class Tasks::Imports::GoogleRecords @user.imports.create(name: @file_path, source: :google_records).id end - def read_file - File.read(@file_path) + def process_file_in_batches(import_id) + batch = [] + + Oj.load_file(@file_path, mode: :compat) do |record| + next unless record.is_a?(Hash) && record['locations'] + + record['locations'].each do |location| + batch << prepare_location_data(location, import_id) + + if batch.size >= BATCH_SIZE + bulk_insert_locations(batch) + batch = [] + end + end + end + + # Process any remaining records + bulk_insert_locations(batch) if batch.any? end - def schedule_import_jobs(json_data, import_id) - json_data['locations'].each do |json| - Import::GoogleTakeoutJob.perform_later(import_id, json.to_json) - end + def prepare_location_data(location, import_id) + { + import_id: import_id, + latitude: location['latitudeE7']&.to_f&. / 1e7, + longitude: location['longitudeE7']&.to_f&. / 1e7, + timestamp: Time.at(location['timestampMs'].to_i / 1000), + accuracy: location['accuracy'], + source_data: location.to_json, + created_at: Time.current, + updated_at: Time.current + } + end + + def bulk_insert_locations(batch) + Location.upsert_all( + batch, + unique_by: %i[import_id timestamp], + returning: false + ) end def log_start From 4c6baad5d4ac5e85f5a12c599731a924ef3352ec Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 13:33:46 +0100 Subject: [PATCH 028/157] Rename unique index on points to `unique_points_lat_long_timestamp_user_id_index` --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ db/migrate/20250120154555_add_unique_index_to_points.rb | 4 ++-- db/schema.rb | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.app_version b/.app_version index ca222b7c..610e2872 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.0 +0.23.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4efd0e..c4c12037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.1 - 2025-01-21 + +### Fixed + +- Renamed unique index on points to `unique_points_lat_long_timestamp_user_id_index` to fix naming conflict with `unique_points_index`. + # 0.23.0 - 2025-01-20 ## ⚠️ IMPORTANT ⚠️ diff --git a/db/migrate/20250120154555_add_unique_index_to_points.rb b/db/migrate/20250120154555_add_unique_index_to_points.rb index fc224ab0..2062c977 100644 --- a/db/migrate/20250120154555_add_unique_index_to_points.rb +++ b/db/migrate/20250120154555_add_unique_index_to_points.rb @@ -6,11 +6,11 @@ class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0] def up add_index :points, %i[latitude longitude timestamp user_id], unique: true, - name: 'unique_points_index', + name: 'unique_points_lat_long_timestamp_user_id_index', algorithm: :concurrently end def down - remove_index :points, name: 'unique_points_index' + remove_index :points, name: 'unique_points_lat_long_timestamp_user_id_index' end end diff --git a/db/schema.rb b/db/schema.rb index 8fc8554c..c4529687 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -168,7 +168,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do t.index ["external_track_id"], name: "index_points_on_external_track_id" t.index ["geodata"], name: "index_points_on_geodata", using: :gin t.index ["import_id"], name: "index_points_on_import_id" - t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_index", unique: true + t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_lat_long_timestamp_user_id_index", unique: true t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at" t.index ["timestamp"], name: "index_points_on_timestamp" From 52335d6a64225b64363a6403806f5845a9b3b68a Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 14:28:00 +0100 Subject: [PATCH 029/157] Add index only if it doesn't exist. --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ .../20250120154555_add_unique_index_to_points.rb | 13 ++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 610e2872..fda96dcf 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.1 +0.23.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c12037..04da2e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.2 - 2025-01-21 + +### Fixed + +- Add index only if it doesn't exist. + # 0.23.1 - 2025-01-21 ### Fixed diff --git a/db/migrate/20250120154555_add_unique_index_to_points.rb b/db/migrate/20250120154555_add_unique_index_to_points.rb index 2062c977..0a408cf7 100644 --- a/db/migrate/20250120154555_add_unique_index_to_points.rb +++ b/db/migrate/20250120154555_add_unique_index_to_points.rb @@ -4,6 +4,11 @@ class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0] disable_ddl_transaction! def up + return if index_exists?( + :points, %i[latitude longitude timestamp user_id], + name: 'unique_points_lat_long_timestamp_user_id_index' + ) + add_index :points, %i[latitude longitude timestamp user_id], unique: true, name: 'unique_points_lat_long_timestamp_user_id_index', @@ -11,6 +16,12 @@ class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0] end def down - remove_index :points, name: 'unique_points_lat_long_timestamp_user_id_index' + return unless index_exists?( + :points, %i[latitude longitude timestamp user_id], + name: 'unique_points_lat_long_timestamp_user_id_index' + ) + + remove_index :points, %i[latitude longitude timestamp user_id], + name: 'unique_points_lat_long_timestamp_user_id_index' end end From 88ffebec3420381c0af94bc83cbc1bde344644e9 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 14:31:12 +0100 Subject: [PATCH 030/157] Update Synology files --- docs/synology/.env | 3 +-- docs/synology/docker-compose.yml | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/synology/.env b/docs/synology/.env index 48273f26..cfc5f19b 100644 --- a/docs/synology/.env +++ b/docs/synology/.env @@ -4,10 +4,9 @@ RAILS_ENV=development MIN_MINUTES_SPENT_IN_CITY=60 -APPLICATION_HOST=dawarich.djhrum.synology.me +APPLICATION_HOSTS=dawarich.example.synology.me TIME_ZONE=Europe/Berlin BACKGROUND_PROCESSING_CONCURRENCY=10 -MAP_CENTER=[52.520826, 13.409690] ################################################################################### # Database diff --git a/docs/synology/docker-compose.yml b/docs/synology/docker-compose.yml index 5544db41..62092437 100644 --- a/docs/synology/docker-compose.yml +++ b/docs/synology/docker-compose.yml @@ -28,7 +28,7 @@ services: - dawarich_redis stdin_open: true tty: true - entrypoint: dev-entrypoint.sh + entrypoint: web-entrypoint.sh command: ['bin/dev'] restart: unless-stopped env_file: @@ -45,7 +45,7 @@ services: - dawarich_db - dawarich_redis - dawarich_app - entrypoint: dev-entrypoint.sh + entrypoint: sidekiq-entrypoint.sh command: ['sidekiq'] restart: unless-stopped env_file: From 06b113aac125c44412c579cb579ef1b651511506 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 14:33:58 +0100 Subject: [PATCH 031/157] Update changelog --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.app_version b/.app_version index fda96dcf..9e40e75c 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.2 +0.23.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 04da2e91..e1b61cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.3 - 2025-01-21 + +### Changed + +- Synology-related files are now up to date. #684 + # 0.23.2 - 2025-01-21 ### Fixed From 0ff47f3ac7e3e9b1f450749fea6fab513c45801b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 16:03:52 +0100 Subject: [PATCH 032/157] Fix Google Records import --- app/services/tasks/imports/google_records.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/services/tasks/imports/google_records.rb b/app/services/tasks/imports/google_records.rb index 9b91f76f..fc74a823 100644 --- a/app/services/tasks/imports/google_records.rb +++ b/app/services/tasks/imports/google_records.rb @@ -39,22 +39,22 @@ class Tasks::Imports::GoogleRecords batch << prepare_location_data(location, import_id) if batch.size >= BATCH_SIZE - bulk_insert_locations(batch) + bulk_insert_points(batch) batch = [] end end end # Process any remaining records - bulk_insert_locations(batch) if batch.any? + bulk_insert_points(batch) if batch.any? end def prepare_location_data(location, import_id) { import_id: import_id, - latitude: location['latitudeE7']&.to_f&. / 1e7, - longitude: location['longitudeE7']&.to_f&. / 1e7, - timestamp: Time.at(location['timestampMs'].to_i / 1000), + latitude: location['latitudeE7']&.to_f&.div(1e7), + longitude: location['longitudeE7']&.to_f&.div(1e7), + timestamp: Time.zone.at(location['timestampMs'].to_i / 1000), accuracy: location['accuracy'], source_data: location.to_json, created_at: Time.current, @@ -62,8 +62,8 @@ class Tasks::Imports::GoogleRecords } end - def bulk_insert_locations(batch) - Location.upsert_all( + def bulk_insert_points(batch) + Point.upsert_all( batch, unique_by: %i[import_id timestamp], returning: false From b43810b1fb23bba9d9cda7beadf2a99b0540f094 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 19:14:36 +0100 Subject: [PATCH 033/157] Import Google Records JSON in batches --- app/jobs/import/google_takeout_job.rb | 6 +- app/models/import.rb | 2 + app/services/google_maps/records_parser.rb | 83 ++++++++++++++------ app/services/tasks/imports/google_records.rb | 42 +++------- config/initializers/sidekiq.rb | 1 + 5 files changed, 77 insertions(+), 57 deletions(-) diff --git a/app/jobs/import/google_takeout_job.rb b/app/jobs/import/google_takeout_job.rb index 4cb74ae2..3bfe965c 100644 --- a/app/jobs/import/google_takeout_job.rb +++ b/app/jobs/import/google_takeout_job.rb @@ -4,10 +4,10 @@ class Import::GoogleTakeoutJob < ApplicationJob queue_as :imports sidekiq_options retry: false - def perform(import_id, json_array) + def perform(import_id, locations, current_index) + locations_batch = Oj.load(locations) import = Import.find(import_id) - records = Oj.load(json_array) - GoogleMaps::RecordsParser.new(import).call(records) + GoogleMaps::RecordsParser.new(import, current_index).call(locations_batch) end end diff --git a/app/models/import.rb b/app/models/import.rb index f396c555..045e8b5f 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -4,6 +4,8 @@ class Import < ApplicationRecord belongs_to :user has_many :points, dependent: :destroy + delegate :count, to: :points, prefix: true + include ImportUploader::Attachment(:raw) enum :source, { diff --git a/app/services/google_maps/records_parser.rb b/app/services/google_maps/records_parser.rb index e2fe2e05..0762e0d6 100644 --- a/app/services/google_maps/records_parser.rb +++ b/app/services/google_maps/records_parser.rb @@ -1,51 +1,88 @@ # frozen_string_literal: true class GoogleMaps::RecordsParser - BATCH_SIZE = 1000 - attr_reader :import + include Imports::Broadcaster - def initialize(import) + BATCH_SIZE = 1000 + attr_reader :import, :current_index + + def initialize(import, current_index = 0) @import = import @batch = [] + @current_index = current_index end - def call(records) - Array(records).each do |record| - @batch << parse_json(record) + def call(locations) + Array(locations).each do |location| + @batch << prepare_location_data(location) + next unless @batch.size >= BATCH_SIZE - if @batch.size >= BATCH_SIZE - bulk_insert_points - @batch = [] - end + bulk_insert_points(@batch) + broadcast_import_progress(import, current_index) + @batch = [] end - bulk_insert_points if @batch.any? + return unless @batch.any? + + bulk_insert_points(@batch) + broadcast_import_progress(import, current_index) end private - def parse_json(json) + # rubocop:disable Metrics/MethodLength + def prepare_location_data(location) { - latitude: json['latitudeE7'].to_f / 10**7, - longitude: json['longitudeE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(json['timestamp'] || json['timestampMs']), - altitude: json['altitude'], - velocity: json['velocity'], - raw_data: json, + latitude: location['latitudeE7'].to_f / 10**7, + longitude: location['longitudeE7'].to_f / 10**7, + timestamp: Timestamps.parse_timestamp(location['timestamp'] || location['timestampMs']), + altitude: location['altitude'], + velocity: location['velocity'], + raw_data: location, topic: 'Google Maps Timeline Export', tracker_id: 'google-maps-timeline-export', - import_id: import.id, - user_id: import.user_id, + import_id: @import.id, + user_id: @import.user_id, created_at: Time.current, updated_at: Time.current } end - def bulk_insert_points + # rubocop:enable Metrics/MethodLength + def bulk_insert_points(batch) + # Deduplicate records within the batch before upserting + # Use all fields in the unique constraint for deduplication + unique_batch = deduplicate_batch(batch) + + # Sort the batch to ensure consistent ordering and prevent deadlocks + # sorted_batch = sort_batch(unique_batch) + Point.upsert_all( - @batch, + unique_batch, unique_by: %i[latitude longitude timestamp user_id], - returning: false + returning: false, + on_duplicate: :skip + ) + rescue StandardError => e + Rails.logger.error("Batch insert failed for import #{@import.id}: #{e.message}") + + # Create notification for the user + Notification.create!( + user: @import.user, + title: 'Google Maps Import Error', + content: "Failed to process location batch: #{e.message}", + kind: :error ) end + + def deduplicate_batch(batch) + batch.uniq do |record| + [ + record[:latitude].round(7), + record[:longitude].round(7), + record[:timestamp], + record[:user_id] + ] + end + end end diff --git a/app/services/tasks/imports/google_records.rb b/app/services/tasks/imports/google_records.rb index fc74a823..83174128 100644 --- a/app/services/tasks/imports/google_records.rb +++ b/app/services/tasks/imports/google_records.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# This class is named based on Google Takeout's Records.json file, -# the main source of user's location history data. +# This class is named based on Google Takeout's Records.json file class Tasks::Imports::GoogleRecords BATCH_SIZE = 1000 # Adjust based on your needs @@ -35,39 +34,20 @@ class Tasks::Imports::GoogleRecords Oj.load_file(@file_path, mode: :compat) do |record| next unless record.is_a?(Hash) && record['locations'] - record['locations'].each do |location| - batch << prepare_location_data(location, import_id) + index = 0 - if batch.size >= BATCH_SIZE - bulk_insert_points(batch) - batch = [] - end + record['locations'].each do |location| + batch << location + + next unless batch.size >= BATCH_SIZE + + index += BATCH_SIZE + Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch), index) + batch = [] end end - # Process any remaining records - bulk_insert_points(batch) if batch.any? - end - - def prepare_location_data(location, import_id) - { - import_id: import_id, - latitude: location['latitudeE7']&.to_f&.div(1e7), - longitude: location['longitudeE7']&.to_f&.div(1e7), - timestamp: Time.zone.at(location['timestampMs'].to_i / 1000), - accuracy: location['accuracy'], - source_data: location.to_json, - created_at: Time.current, - updated_at: Time.current - } - end - - def bulk_insert_points(batch) - Point.upsert_all( - batch, - unique_by: %i[import_id timestamp], - returning: false - ) + Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch)) if batch.any? end def log_start diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index d9dec786..ab3f00c5 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -2,6 +2,7 @@ Sidekiq.configure_server do |config| config.redis = { url: ENV['REDIS_URL'] } + config.logger = Sidekiq::Logger.new($stdout) if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' require 'prometheus_exporter/instrumentation' From 510868a5940047e330b1f1e4f1c5ae633f2d0f58 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 19:32:12 +0100 Subject: [PATCH 034/157] Fix failed specs --- app/services/google_maps/records_parser.rb | 6 +- .../google_maps/records_parser_spec.rb | 65 ++++++++++++++----- .../tasks/imports/google_records_spec.rb | 4 +- spec/tasks/import_spec.rb | 2 +- 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/app/services/google_maps/records_parser.rb b/app/services/google_maps/records_parser.rb index 0762e0d6..32f02723 100644 --- a/app/services/google_maps/records_parser.rb +++ b/app/services/google_maps/records_parser.rb @@ -35,7 +35,7 @@ class GoogleMaps::RecordsParser { latitude: location['latitudeE7'].to_f / 10**7, longitude: location['longitudeE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(location['timestamp'] || location['timestampMs']), + timestamp: parse_timestamp(location), altitude: location['altitude'], velocity: location['velocity'], raw_data: location, @@ -85,4 +85,8 @@ class GoogleMaps::RecordsParser ] end end + + def parse_timestamp(location) + Timestamps.parse_timestamp(location['timestamp'] || location['timestampMs']) + end end diff --git a/spec/services/google_maps/records_parser_spec.rb b/spec/services/google_maps/records_parser_spec.rb index 96495dad..da99f501 100644 --- a/spec/services/google_maps/records_parser_spec.rb +++ b/spec/services/google_maps/records_parser_spec.rb @@ -4,21 +4,36 @@ require 'rails_helper' RSpec.describe GoogleMaps::RecordsParser do describe '#call' do - subject(:parser) { described_class.new(import).call(json) } + subject(:parser) { described_class.new(import).call(locations) } let(:import) { create(:import) } let(:time) { DateTime.new(2025, 1, 1, 12, 0, 0) } - let(:json) do - { - 'latitudeE7' => 123_456_789, - 'longitudeE7' => 123_456_789, - 'altitude' => 0, - 'velocity' => 0 - } + let(:locations) do + [ + { + 'timestampMs' => (time.to_f * 1000).to_i.to_s, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789, + 'accuracy' => 10, + 'altitude' => 100, + 'verticalAccuracy' => 5, + 'activity' => [ + { + 'timestampMs' => (time.to_f * 1000).to_i.to_s, + 'activity' => [ + { + 'type' => 'STILL', + 'confidence' => 100 + } + ] + } + ] + } + ] end context 'with regular timestamp' do - let(:json) { super().merge('timestamp' => time.to_s) } + let(:locations) { super()[0].merge('timestamp' => time.to_s).to_json } it 'creates a point' do expect { parser }.to change(Point, :count).by(1) @@ -26,11 +41,23 @@ RSpec.describe GoogleMaps::RecordsParser do end context 'when point already exists' do - let(:json) { super().merge('timestamp' => time.to_s) } + let(:locations) do + [ + super()[0].merge( + 'timestamp' => time.to_s, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789 + ) + ] + end before do create( - :point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789, + :point, + user: import.user, + import: import, + latitude: 12.3456789, + longitude: 12.3456789, timestamp: time.to_i ) end @@ -41,7 +68,9 @@ RSpec.describe GoogleMaps::RecordsParser do end context 'with timestampMs in milliseconds' do - let(:json) { super().merge('timestampMs' => (time.to_f * 1000).to_i.to_s) } + let(:locations) do + [super()[0].merge('timestampMs' => (time.to_f * 1000).to_i.to_s)] + end it 'creates a point using milliseconds timestamp' do expect { parser }.to change(Point, :count).by(1) @@ -49,7 +78,9 @@ RSpec.describe GoogleMaps::RecordsParser do end context 'with ISO 8601 timestamp' do - let(:json) { super().merge('timestamp' => time.iso8601) } + let(:locations) do + [super()[0].merge('timestamp' => time.iso8601)] + end it 'parses ISO 8601 timestamp correctly' do expect { parser }.to change(Point, :count).by(1) @@ -59,7 +90,9 @@ RSpec.describe GoogleMaps::RecordsParser do end context 'with timestamp in milliseconds' do - let(:json) { super().merge('timestamp' => (time.to_f * 1000).to_i.to_s) } + let(:locations) do + [super()[0].merge('timestamp' => (time.to_f * 1000).to_i.to_s)] + end it 'parses millisecond timestamp correctly' do expect { parser }.to change(Point, :count).by(1) @@ -69,7 +102,9 @@ RSpec.describe GoogleMaps::RecordsParser do end context 'with timestamp in seconds' do - let(:json) { super().merge('timestamp' => time.to_i.to_s) } + let(:locations) do + [super()[0].merge('timestamp' => time.to_i.to_s)] + end it 'parses second timestamp correctly' do expect { parser }.to change(Point, :count).by(1) diff --git a/spec/services/tasks/imports/google_records_spec.rb b/spec/services/tasks/imports/google_records_spec.rb index 0310dbd1..29fddfdf 100644 --- a/spec/services/tasks/imports/google_records_spec.rb +++ b/spec/services/tasks/imports/google_records_spec.rb @@ -5,10 +5,10 @@ require 'rails_helper' RSpec.describe Tasks::Imports::GoogleRecords do describe '#call' do let(:user) { create(:user) } - let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json').to_s } it 'schedules the Import::GoogleTakeoutJob' do - expect(Import::GoogleTakeoutJob).to receive(:perform_later).exactly(3).times + expect(Import::GoogleTakeoutJob).to receive(:perform_later).exactly(1).time described_class.new(file_path, user.email).call end diff --git a/spec/tasks/import_spec.rb b/spec/tasks/import_spec.rb index 4cd785db..0e718f76 100644 --- a/spec/tasks/import_spec.rb +++ b/spec/tasks/import_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe 'import.rake' do - let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json').to_s } let(:user) { create(:user) } it 'calls importing class' do From 591543fe98ab58993d60d5d7eaa5c025c9d87aed Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 19:42:04 +0100 Subject: [PATCH 035/157] Rename GoogleMaps::RecordsParser to GoogleMaps::RecordsImporter --- app/jobs/import/google_takeout_job.rb | 2 +- ...{records_parser.rb => records_importer.rb} | 48 ++++++++----------- ...arser_spec.rb => records_importer_spec.rb} | 2 +- 3 files changed, 22 insertions(+), 30 deletions(-) rename app/services/google_maps/{records_parser.rb => records_importer.rb} (63%) rename spec/services/google_maps/{records_parser_spec.rb => records_importer_spec.rb} (98%) diff --git a/app/jobs/import/google_takeout_job.rb b/app/jobs/import/google_takeout_job.rb index 3bfe965c..02702cf7 100644 --- a/app/jobs/import/google_takeout_job.rb +++ b/app/jobs/import/google_takeout_job.rb @@ -8,6 +8,6 @@ class Import::GoogleTakeoutJob < ApplicationJob locations_batch = Oj.load(locations) import = Import.find(import_id) - GoogleMaps::RecordsParser.new(import, current_index).call(locations_batch) + GoogleMaps::RecordsImporter.new(import, current_index).call(locations_batch) end end diff --git a/app/services/google_maps/records_parser.rb b/app/services/google_maps/records_importer.rb similarity index 63% rename from app/services/google_maps/records_parser.rb rename to app/services/google_maps/records_importer.rb index 32f02723..c7edfb1f 100644 --- a/app/services/google_maps/records_parser.rb +++ b/app/services/google_maps/records_importer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GoogleMaps::RecordsParser +class GoogleMaps::RecordsImporter include Imports::Broadcaster BATCH_SIZE = 1000 @@ -13,19 +13,11 @@ class GoogleMaps::RecordsParser end def call(locations) - Array(locations).each do |location| - @batch << prepare_location_data(location) - next unless @batch.size >= BATCH_SIZE - - bulk_insert_points(@batch) + Array(locations).each_slice(BATCH_SIZE) do |location_batch| + batch = location_batch.map { prepare_location_data(_1) } + bulk_insert_points(batch) broadcast_import_progress(import, current_index) - @batch = [] end - - return unless @batch.any? - - bulk_insert_points(@batch) - broadcast_import_progress(import, current_index) end private @@ -47,32 +39,21 @@ class GoogleMaps::RecordsParser updated_at: Time.current } end - # rubocop:enable Metrics/MethodLength + def bulk_insert_points(batch) - # Deduplicate records within the batch before upserting - # Use all fields in the unique constraint for deduplication unique_batch = deduplicate_batch(batch) - # Sort the batch to ensure consistent ordering and prevent deadlocks - # sorted_batch = sort_batch(unique_batch) - + # rubocop:disable Rails/SkipsModelValidations Point.upsert_all( unique_batch, unique_by: %i[latitude longitude timestamp user_id], returning: false, on_duplicate: :skip ) + # rubocop:enable Rails/SkipsModelValidations rescue StandardError => e - Rails.logger.error("Batch insert failed for import #{@import.id}: #{e.message}") - - # Create notification for the user - Notification.create!( - user: @import.user, - title: 'Google Maps Import Error', - content: "Failed to process location batch: #{e.message}", - kind: :error - ) + create_notification("Failed to process location batch: #{e.message}") end def deduplicate_batch(batch) @@ -87,6 +68,17 @@ class GoogleMaps::RecordsParser end def parse_timestamp(location) - Timestamps.parse_timestamp(location['timestamp'] || location['timestampMs']) + Timestamps.parse_timestamp( + location['timestamp'] || location['timestampMs'] + ) + end + + def create_notification(message) + Notification.create!( + user: @import.user, + title: 'Google\'s Records.json Import Error', + content: message, + kind: :error + ) end end diff --git a/spec/services/google_maps/records_parser_spec.rb b/spec/services/google_maps/records_importer_spec.rb similarity index 98% rename from spec/services/google_maps/records_parser_spec.rb rename to spec/services/google_maps/records_importer_spec.rb index da99f501..8ce4d69d 100644 --- a/spec/services/google_maps/records_parser_spec.rb +++ b/spec/services/google_maps/records_importer_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe GoogleMaps::RecordsParser do +RSpec.describe GoogleMaps::RecordsImporter do describe '#call' do subject(:parser) { described_class.new(import).call(locations) } From 9cf3685eb6516b4ae68e410268be5bb29fb5766e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 19:46:23 +0100 Subject: [PATCH 036/157] Update app version and changelog --- .app_version | 2 +- CHANGELOG.md | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index fda96dcf..9e40e75c 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.2 +0.23.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 04da2e91..51f3f561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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.23.2 - 2025-01-21 +# 0.23.3 - 2025-01-21 + +### Fixed + +- Drastically improved performance for Google's Records.json import. ### Fixed From 96b3a1b02200e0f4f4bd985ad9c1be266564e352 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 23:28:02 +0100 Subject: [PATCH 037/157] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f3f561..f50e154a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Drastically improved performance for Google's Records.json import. +- Drastically improved performance for Google's Records.json import. It will now take less than 5 minutes to import 500,000 points, which previously took a few hours. ### Fixed From 356d790fe36feb836311ce7d4f311e0799640180 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 21 Jan 2025 23:46:33 +0100 Subject: [PATCH 038/157] Update build_and_push workflow to build rc Docker image --- .app_version | 2 +- .github/workflows/build_and_push.yml | 34 ++++++++++++++++++++++++---- CHANGELOG.md | 6 +++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.app_version b/.app_version index 9e40e75c..40a6dfed 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.3 +0.23.4 diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 00a78a71..90c78ae1 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -15,13 +15,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch || github.ref_name }} + - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers uses: actions/cache@v4 with: @@ -29,20 +32,41 @@ jobs: key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- + - name: Install dependencies run: npm install + - name: Login to Docker Hub uses: docker/login-action@v3.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set Docker tags + id: docker_meta + run: | + VERSION=${GITHUB_REF#refs/tags/} + TAGS="freikin/dawarich:${VERSION}" + + # Add :rc tag for pre-releases + if [ "${{ github.event.release.prerelease }}" = "true" ]; then + TAGS="${TAGS},freikin/dawarich:rc" + fi + + # Add :latest tag only if release is not a pre-release + if [ "${{ github.event.release.prerelease }}" != "true" ]; then + TAGS="${TAGS},freikin/dawarich:latest" + fi + + echo "tags=${TAGS}" >> $GITHUB_OUTPUT + - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: ./docker/Dockerfile.dev push: true - tags: freikin/dawarich:latest,freikin/dawarich:${{ github.event.inputs.branch || github.ref_name }} + tags: ${{ steps.docker_meta.outputs.tags }} platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 1608203b..81a8b8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.4 - 2025-01-21 + +### Added + +- A test for building rc Docker image. + # 0.23.3 - 2025-01-21 ### Changed From c3243bdba0f5647ec65095a853e2ded56daffb03 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 22 Jan 2025 10:53:33 +0100 Subject: [PATCH 039/157] Fix authentication to `GET /api/v1/countries/visited_cities` --- CHANGELOG.md | 7 ++++++- .../api/v1/countries/visited_cities_controller.rb | 2 +- app/services/tasks/imports/google_records.rb | 5 ++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a8b8e6..93bbcd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,17 @@ 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.23.4 - 2025-01-21 +# 0.23.4 - 2025-01-22 ### Added - A test for building rc Docker image. +### Fixed + +- Fix authentication to `GET /api/v1/countries/visited_cities` with header `Authorization: Bearer YOUR_API_KEY` instead of `api_key` query param. #679 + + # 0.23.3 - 2025-01-21 ### Changed diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb index 125baf8e..85e53f7d 100644 --- a/app/controllers/api/v1/countries/visited_cities_controller.rb +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -17,6 +17,6 @@ class Api::V1::Countries::VisitedCitiesController < ApiController private def required_params - %i[start_at end_at api_key] + %i[start_at end_at] end end diff --git a/app/services/tasks/imports/google_records.rb b/app/services/tasks/imports/google_records.rb index 83174128..70b5d389 100644 --- a/app/services/tasks/imports/google_records.rb +++ b/app/services/tasks/imports/google_records.rb @@ -30,12 +30,11 @@ class Tasks::Imports::GoogleRecords def process_file_in_batches(import_id) batch = [] + index = 0 Oj.load_file(@file_path, mode: :compat) do |record| next unless record.is_a?(Hash) && record['locations'] - index = 0 - record['locations'].each do |location| batch << location @@ -47,7 +46,7 @@ class Tasks::Imports::GoogleRecords end end - Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch)) if batch.any? + Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch), index) if batch.any? end def log_start From 157d916f6bfd9a1c815cfeed92c460ec824b2d85 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 22 Jan 2025 11:15:55 +0100 Subject: [PATCH 040/157] Fix a bug where a gpx file with empty tracks was not being imported --- CHANGELOG.md | 5 +++ app/services/gpx/track_parser.rb | 4 ++- spec/fixtures/files/gpx/arc_example.gpx | 41 +++++++++++++++++++++++++ spec/services/gpx/track_parser_spec.rb | 10 ++++++ 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/files/gpx/arc_example.gpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a8b8e6..d6609218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - A test for building rc Docker image. +### Fixed + +- Fix authentication to `GET /api/v1/countries/visited_cities` with header `Authorization: Bearer YOUR_API_KEY` instead of `api_key` query param. #679 +- Fix a bug where a gpx file with empty tracks was not being imported. #646 + # 0.23.3 - 2025-01-21 ### Changed diff --git a/app/services/gpx/track_parser.rb b/app/services/gpx/track_parser.rb index 65cbc3be..10f13983 100644 --- a/app/services/gpx/track_parser.rb +++ b/app/services/gpx/track_parser.rb @@ -15,7 +15,7 @@ class Gpx::TrackParser tracks = json['gpx']['trk'] tracks_arr = tracks.is_a?(Array) ? tracks : [tracks] - tracks_arr.map { parse_track(_1) }.flatten.each.with_index(1) do |point, index| + tracks_arr.map { parse_track(_1) }.flatten.compact.each.with_index(1) do |point, index| create_point(point, index) end end @@ -23,6 +23,8 @@ class Gpx::TrackParser private def parse_track(track) + return if track['trkseg'].blank? + segments = track['trkseg'] segments_array = segments.is_a?(Array) ? segments : [segments] diff --git a/spec/fixtures/files/gpx/arc_example.gpx b/spec/fixtures/files/gpx/arc_example.gpx new file mode 100644 index 00000000..f944f776 --- /dev/null +++ b/spec/fixtures/files/gpx/arc_example.gpx @@ -0,0 +1,41 @@ + + + + + 89.9031832732575 + Topland Hotel & Convention Center + + + walking + + + + taxi + + + 49.96302288016834 + + + + 49.884678590538186 + + + + 49.71960135141746 + + + + 49.91594081568717 + + + + 50.344669848377556 + + + + 50.12800953488726 + + + + + diff --git a/spec/services/gpx/track_parser_spec.rb b/spec/services/gpx/track_parser_spec.rb index b1026143..c5980c91 100644 --- a/spec/services/gpx/track_parser_spec.rb +++ b/spec/services/gpx/track_parser_spec.rb @@ -74,5 +74,15 @@ RSpec.describe Gpx::TrackParser do expect(Point.first.velocity).to eq('2.8') end end + + context 'when file exported from Arc' do + context 'when file has empty tracks' do + let(:file_path) { Rails.root.join('spec/fixtures/files/gpx/arc_example.gpx') } + + it 'creates points' do + expect { parser }.to change { Point.count }.by(6) + end + end + end end end From f60c93ee4f9362a5d1bfa14b2a6c26f604929dee Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 22 Jan 2025 12:17:26 +0100 Subject: [PATCH 041/157] Fix a bug where rc version was being checked as a stable release --- CHANGELOG.md | 1 + app/services/check_app_version.rb | 5 ++++- spec/services/check_app_version_spec.rb | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e1952e..31b55115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fix authentication to `GET /api/v1/countries/visited_cities` with header `Authorization: Bearer YOUR_API_KEY` instead of `api_key` query param. #679 - Fix a bug where a gpx file with empty tracks was not being imported. #646 +- Fix a bug where rc version was being checked as a stable release. #711 # 0.23.3 - 2025-01-21 diff --git a/app/services/check_app_version.rb b/app/services/check_app_version.rb index 3ae5ed76..bb2fd449 100644 --- a/app/services/check_app_version.rb +++ b/app/services/check_app_version.rb @@ -17,7 +17,10 @@ class CheckAppVersion def latest_version Rails.cache.fetch(VERSION_CACHE_KEY, expires_in: 6.hours) do - JSON.parse(Net::HTTP.get(URI.parse(@repo_url)))[0]['name'] + versions = JSON.parse(Net::HTTP.get(URI.parse(@repo_url))) + # Find first version that contains only numbers and dots + release_version = versions.find { |v| v['name'].match?(/^\d+\.\d+\.\d+$/) } + release_version ? release_version['name'] : APP_VERSION end end end diff --git a/spec/services/check_app_version_spec.rb b/spec/services/check_app_version_spec.rb index b58cc2e5..1e90b3af 100644 --- a/spec/services/check_app_version_spec.rb +++ b/spec/services/check_app_version_spec.rb @@ -29,6 +29,15 @@ RSpec.describe CheckAppVersion do it { is_expected.to be true } end + context 'when latest version is not a stable release' do + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0-rc.1"}]', headers: {}) + end + + it { is_expected.to be false } + end + context 'when request fails' do before do allow(Net::HTTP).to receive(:get).and_raise(StandardError) From f4027551e62c0aa8477234869d84daee60086eea Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 22 Jan 2025 15:06:35 +0100 Subject: [PATCH 042/157] Update StrongMigrations start_after --- config/initializers/strong_migrations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/strong_migrations.rb b/config/initializers/strong_migrations.rb index 04e43a9e..ec978211 100644 --- a/config/initializers/strong_migrations.rb +++ b/config/initializers/strong_migrations.rb @@ -1,5 +1,5 @@ # Mark existing migrations as safe -StrongMigrations.start_after = 20241225175637 +StrongMigrations.start_after = 20_250_122_150_500 # Set timeouts for migrations # If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user From b0e6d47648ebefb08976f8038fadcb24dce5d3b0 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 22 Jan 2025 15:11:49 +0100 Subject: [PATCH 043/157] Change version --- .app_version | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 40a6dfed..f6de0017 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.4 +0.23.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b55115..6f117299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,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.23.4 - 2025-01-22 +# 0.23.5 - 2025-01-22 ### Added From 9077889b404aeeffc60a33a0f10335107dc325d9 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 23 Jan 2025 14:03:08 +0100 Subject: [PATCH 044/157] Add PostGIS adapter --- Gemfile | 1 + Gemfile.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/Gemfile b/Gemfile index 1066cbfa..92c6d14f 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,7 @@ gem 'lograge' gem 'oj' gem 'pg' gem 'prometheus_exporter' +gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapter', branch: 'rails-8' gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' diff --git a/Gemfile.lock b/Gemfile.lock index 43f74521..5460cf07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +GIT + remote: https://github.com/StoneGod/activerecord-postgis-adapter.git + revision: 147fd43191ef703e2a1b3654f31d9139201a87e8 + branch: rails-8 + specs: + activerecord-postgis-adapter (10.0.1) + activerecord (~> 8.0.0) + rgeo-activerecord (~> 8.0.0) + GIT remote: https://github.com/alexreisner/geocoder.git revision: 04ee2936a30b30a23ded5231d7faf6cf6c27c099 @@ -314,6 +323,10 @@ GEM actionpack (>= 5.2) railties (>= 5.2) rexml (3.3.8) + rgeo (3.0.1) + rgeo-activerecord (8.0.0) + activerecord (>= 7.0) + rgeo (>= 3.0) rspec-core (3.13.2) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -443,6 +456,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + activerecord-postgis-adapter! bootsnap chartkick data_migrate From 66930a340f28ff44c4d8e3b4c3d3d906394a1cdc Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 23 Jan 2025 15:36:10 +0100 Subject: [PATCH 045/157] Update CHANGELOG.md --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.app_version b/.app_version index f6de0017..df47809d 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.5 +0.23.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f117299..1b70a9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.6 - 2025-01-23 + +### Added + +- Enabled Postgis extension for PostgreSQL. + # 0.23.5 - 2025-01-22 ### Added From 63b92f695f14365e97a41197b0baa35daea70ba5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 23 Jan 2025 15:53:32 +0100 Subject: [PATCH 046/157] Enable Postgis extension for PostgreSQL --- db/migrate/20250123145155_enable_postgis_extension.rb | 7 +++++++ db/schema.rb | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250123145155_enable_postgis_extension.rb diff --git a/db/migrate/20250123145155_enable_postgis_extension.rb b/db/migrate/20250123145155_enable_postgis_extension.rb new file mode 100644 index 00000000..e9d816dd --- /dev/null +++ b/db/migrate/20250123145155_enable_postgis_extension.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class EnablePostgisExtension < ActiveRecord::Migration[8.0] + def change + enable_extension 'postgis' + end +end diff --git a/db/schema.rb b/db/schema.rb index ebf1007e..f865b48d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do +ActiveRecord::Schema[8.0].define(version: 2025_01_23_145155) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + enable_extension "postgis" create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false @@ -177,6 +178,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do t.index ["visit_id"], name: "index_points_on_visit_id" end + create_table "spatial_ref_sys", primary_key: "srid", id: :integer, default: nil, force: :cascade do |t| + t.string "auth_name", limit: 256 + t.integer "auth_srid" + t.string "srtext", limit: 2048 + t.string "proj4text", limit: 2048 + t.check_constraint "srid > 0 AND srid <= 998999", name: "spatial_ref_sys_srid_check" + end + create_table "stats", force: :cascade do |t| t.integer "year", null: false t.integer "month", null: false From 774de9991b71b099a75d0bb5b55170ad0342fd48 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 23 Jan 2025 16:03:21 +0100 Subject: [PATCH 047/157] Add tracks model --- app/models/track.rb | 7 +++++++ app/models/user.rb | 3 ++- config/database.yml | 2 +- db/migrate/20250123145954_create_tracks.rb | 14 ++++++++++++++ db/schema.rb | 21 ++++++++++++--------- spec/factories/tracks.rb | 10 ++++++++++ spec/models/track_spec.rb | 15 +++++++++++++++ spec/models/user_spec.rb | 1 + 8 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 app/models/track.rb create mode 100644 db/migrate/20250123145954_create_tracks.rb create mode 100644 spec/factories/tracks.rb create mode 100644 spec/models/track_spec.rb diff --git a/app/models/track.rb b/app/models/track.rb new file mode 100644 index 00000000..41e673b4 --- /dev/null +++ b/app/models/track.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Track < ApplicationRecord + belongs_to :user + + validates :path, :started_at, :ended_at, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index b3112130..90ff2fb0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,7 +13,8 @@ class User < ApplicationRecord has_many :visits, dependent: :destroy has_many :points, through: :imports has_many :places, through: :visits - has_many :trips, dependent: :destroy + has_many :trips, dependent: :destroy + has_many :tracks, dependent: :destroy after_create :create_api_key before_save :strip_trailing_slashes diff --git a/config/database.yml b/config/database.yml index fca7a51c..79ad2b3b 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,5 +1,5 @@ default: &default - adapter: postgresql + adapter: postgis encoding: unicode database: <%= ENV['DATABASE_NAME'] %> username: <%= ENV['DATABASE_USERNAME'] %> diff --git a/db/migrate/20250123145954_create_tracks.rb b/db/migrate/20250123145954_create_tracks.rb new file mode 100644 index 00000000..168d2c12 --- /dev/null +++ b/db/migrate/20250123145954_create_tracks.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTracks < ActiveRecord::Migration[8.0] + def change + create_table :tracks do |t| + t.datetime :started_at, null: false + t.datetime :ended_at, null: false + t.references :user, null: false, foreign_key: true + t.line_string :path, srid: 3785, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f865b48d..a85c60bd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_23_145155) do +ActiveRecord::Schema[8.0].define(version: 2025_01_23_145954) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -178,14 +178,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_145155) do t.index ["visit_id"], name: "index_points_on_visit_id" end - create_table "spatial_ref_sys", primary_key: "srid", id: :integer, default: nil, force: :cascade do |t| - t.string "auth_name", limit: 256 - t.integer "auth_srid" - t.string "srtext", limit: 2048 - t.string "proj4text", limit: 2048 - t.check_constraint "srid > 0 AND srid <= 998999", name: "spatial_ref_sys_srid_check" - end - create_table "stats", force: :cascade do |t| t.integer "year", null: false t.integer "month", null: false @@ -201,6 +193,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_145155) do t.index ["year"], name: "index_stats_on_year" end + create_table "tracks", force: :cascade do |t| + t.datetime "started_at", null: false + t.datetime "ended_at", null: false + t.bigint "user_id", null: false + t.geometry "path", limit: {:srid=>3785, :type=>"line_string"}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_tracks_on_user_id" + end + create_table "trips", force: :cascade do |t| t.string "name", null: false t.datetime "started_at", null: false @@ -261,6 +263,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_145155) do add_foreign_key "points", "users" add_foreign_key "points", "visits" add_foreign_key "stats", "users" + add_foreign_key "tracks", "users" add_foreign_key "trips", "users" add_foreign_key "visits", "areas" add_foreign_key "visits", "places" diff --git a/spec/factories/tracks.rb b/spec/factories/tracks.rb new file mode 100644 index 00000000..32603460 --- /dev/null +++ b/spec/factories/tracks.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :track do + started_at { DateTime.new(2025, 1, 23, 15, 59, 55) } + ended_at { DateTime.new(2025, 1, 23, 16, 0, 0) } + user + path { 'LINESTRING(0 0, 1 1, 2 2)' } + end +end diff --git a/spec/models/track_spec.rb b/spec/models/track_spec.rb new file mode 100644 index 00000000..051b8ae8 --- /dev/null +++ b/spec/models/track_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Track, type: :model do + describe 'validations' do + it { is_expected.to validate_presence_of(:path) } + it { is_expected.to validate_presence_of(:started_at) } + it { is_expected.to validate_presence_of(:ended_at) } + end + + describe 'associations' do + it { is_expected.to belong_to(:user) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 398e436f..a9ce1d1e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,6 +14,7 @@ RSpec.describe User, type: :model do it { is_expected.to have_many(:visits).dependent(:destroy) } it { is_expected.to have_many(:places).through(:visits) } it { is_expected.to have_many(:trips).dependent(:destroy) } + it { is_expected.to have_many(:tracks).dependent(:destroy) } end describe 'callbacks' do From 7db7fb17d55d490ab1ad6b3b8849b80c91c3b11c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 12:01:54 +0100 Subject: [PATCH 048/157] Add path to trips --- Gemfile | 1 + Gemfile.lock | 1 + .../controllers/trips_controller.js | 26 +++++++++++-------- app/jobs/trips/create_path_job.rb | 10 +++++++ app/models/trip.rb | 6 +++++ app/services/tracks/build_path.rb | 21 +++++++++++++++ app/views/trips/show.html.erb | 1 + config/initializers/03_dawarich_settings.rb | 8 ++++++ .../20250123151849_create_paths_for_trips.rb | 10 +++++++ db/migrate/20250123145954_create_tracks.rb | 2 +- .../20250123151657_add_path_to_trips.rb | 7 +++++ db/schema.rb | 5 ++-- spec/jobs/trips/create_path_job_spec.rb | 5 ++++ 13 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 app/jobs/trips/create_path_job.rb create mode 100644 app/services/tracks/build_path.rb create mode 100644 db/data/20250123151849_create_paths_for_trips.rb create mode 100644 db/migrate/20250123151657_add_path_to_trips.rb create mode 100644 spec/jobs/trips/create_path_job_spec.rb diff --git a/Gemfile b/Gemfile index 92c6d14f..592c2fd3 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapt gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' +gem 'rgeo' gem 'rswag-api' gem 'rswag-ui' gem 'shrine', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index 5460cf07..8407dd3a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -484,6 +484,7 @@ DEPENDENCIES pundit rails (~> 8.0) redis + rgeo rspec-rails rswag-api rswag-specs diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 602c04be..f512a208 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -1,17 +1,21 @@ import { Controller } from "@hotwired/stimulus" import L from "leaflet" -import { osmMapLayer } from "../maps/layers" +import { + osmMapLayer, + osmHotMapLayer, + OPNVMapLayer, + openTopoMapLayer, + cyclOsmMapLayer, + esriWorldStreetMapLayer, + esriWorldTopoMapLayer, + esriWorldImageryMapLayer, + esriWorldGrayCanvasMapLayer +} from "../maps/layers" import { createPopupContent } from "../maps/popups" -import { osmHotMapLayer } from "../maps/layers" -import { OPNVMapLayer } from "../maps/layers" -import { openTopoMapLayer } from "../maps/layers" -import { cyclOsmMapLayer } from "../maps/layers" -import { esriWorldStreetMapLayer } from "../maps/layers" -import { esriWorldTopoMapLayer } from "../maps/layers" -import { esriWorldImageryMapLayer } from "../maps/layers" -import { esriWorldGrayCanvasMapLayer } from "../maps/layers" -import { fetchAndDisplayPhotos } from '../maps/helpers'; -import { showFlashMessage } from "../maps/helpers"; +import { + fetchAndDisplayPhotos, + showFlashMessage +} from '../maps/helpers'; export default class extends Controller { static targets = ["container", "startedAt", "endedAt"] diff --git a/app/jobs/trips/create_path_job.rb b/app/jobs/trips/create_path_job.rb new file mode 100644 index 00000000..f36fa7cd --- /dev/null +++ b/app/jobs/trips/create_path_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Trips::CreatePathJob < ApplicationJob + queue_as :default + + def perform(trip_id) + trip = Trip.find(trip_id) + trip.create_path! + end +end diff --git a/app/models/trip.rb b/app/models/trip.rb index 4a2b0302..00c2774a 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -9,6 +9,12 @@ class Trip < ApplicationRecord before_save :calculate_distance + def create_path! + self.path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + + save! + end + def points user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) end diff --git a/app/services/tracks/build_path.rb b/app/services/tracks/build_path.rb new file mode 100644 index 00000000..4feaf49c --- /dev/null +++ b/app/services/tracks/build_path.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Tracks::BuildPath + def initialize(coordinates) + @coordinates = coordinates + end + + def call + factory.line_string( + coordinates.map { |point| factory.point(point[1].to_f.round(5), point[0].to_f.round(5)) } + ) + end + + private + + attr_reader :coordinates + + def factory + @factory ||= RGeo::Geographic.spherical_factory(srid: 3857) + end +end diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb index f399eb3f..9d6dc4ee 100644 --- a/app/views/trips/show.html.erb +++ b/app/views/trips/show.html.erb @@ -25,6 +25,7 @@ data-api_key="<%= current_user.api_key %>" data-user_settings="<%= current_user.settings.to_json %>" data-coordinates="<%= @coordinates.to_json %>" + data-path="<%= @trip.path.to_json %>" data-timezone="<%= Rails.configuration.time_zone %>">
diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb index 87cf4817..451ed716 100644 --- a/config/initializers/03_dawarich_settings.rb +++ b/config/initializers/03_dawarich_settings.rb @@ -17,5 +17,13 @@ class DawarichSettings def geoapify_enabled? @geoapify_enabled ||= GEOAPIFY_API_KEY.present? end + + def meters_between_tracks + @meters_between_tracks ||= 300 + end + + def minutes_between_tracks + @minutes_between_tracks ||= 20 + end end end diff --git a/db/data/20250123151849_create_paths_for_trips.rb b/db/data/20250123151849_create_paths_for_trips.rb new file mode 100644 index 00000000..6abcfff4 --- /dev/null +++ b/db/data/20250123151849_create_paths_for_trips.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CreatePathsForTrips < ActiveRecord::Migration[8.0] + def up + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20250123145954_create_tracks.rb b/db/migrate/20250123145954_create_tracks.rb index 168d2c12..35c6afa1 100644 --- a/db/migrate/20250123145954_create_tracks.rb +++ b/db/migrate/20250123145954_create_tracks.rb @@ -6,7 +6,7 @@ class CreateTracks < ActiveRecord::Migration[8.0] t.datetime :started_at, null: false t.datetime :ended_at, null: false t.references :user, null: false, foreign_key: true - t.line_string :path, srid: 3785, null: false + t.line_string :path, srid: 3857, null: false t.timestamps end diff --git a/db/migrate/20250123151657_add_path_to_trips.rb b/db/migrate/20250123151657_add_path_to_trips.rb new file mode 100644 index 00000000..a5f121e7 --- /dev/null +++ b/db/migrate/20250123151657_add_path_to_trips.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPathToTrips < ActiveRecord::Migration[8.0] + def change + add_column :trips, :path, :line_string, srid: 3857 + end +end diff --git a/db/schema.rb b/db/schema.rb index a85c60bd..7e9cca52 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_23_145954) do +ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -197,7 +197,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_145954) do t.datetime "started_at", null: false t.datetime "ended_at", null: false t.bigint "user_id", null: false - t.geometry "path", limit: {:srid=>3785, :type=>"line_string"}, null: false + t.geometry "path", limit: {:srid=>3857, :type=>"line_string"}, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["user_id"], name: "index_tracks_on_user_id" @@ -211,6 +211,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_145954) do t.bigint "user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.geometry "path", limit: {:srid=>3857, :type=>"line_string"} t.index ["user_id"], name: "index_trips_on_user_id" end diff --git a/spec/jobs/trips/create_path_job_spec.rb b/spec/jobs/trips/create_path_job_spec.rb new file mode 100644 index 00000000..1dd711ef --- /dev/null +++ b/spec/jobs/trips/create_path_job_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Trips::CreatePathJob, type: :job do + pending "add some examples to (or delete) #{__FILE__}" +end From 1e7efbc9afbb8ed89cdd252a802f24386f6884a7 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 14:54:10 +0100 Subject: [PATCH 049/157] Render trips using precalculated paths instead of list of coordinates --- CHANGELOG.md | 2 + app/controllers/trips_controller.rb | 5 -- .../controllers/trips_controller.js | 51 ++++++++++++++----- app/views/trips/show.html.erb | 1 - 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b70a9eb..e66d12ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Enabled Postgis extension for PostgreSQL. +- Trips are now store their paths in the database independently of the points. +- Trips are now being rendered on the map using their precalculated paths instead of list of coordinates. # 0.23.5 - 2025-01-22 diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index 2a9a26d2..038d4842 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -10,11 +10,6 @@ class TripsController < ApplicationController end def show - @coordinates = @trip.points.pluck( - :latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, - :country - ).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] } - @photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do @trip.photo_previews end diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index f512a208..c40b1426 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -27,9 +27,9 @@ export default class extends Controller { } console.log("Trips controller connected") - this.coordinates = JSON.parse(this.containerTarget.dataset.coordinates) + this.apiKey = this.containerTarget.dataset.api_key - this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings) + this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings || '{}') this.timezone = this.containerTarget.dataset.timezone this.distanceUnit = this.containerTarget.dataset.distance_unit @@ -46,16 +46,12 @@ export default class extends Controller { // Move map initialization to separate method initializeMap() { // Initialize layer groups - this.markersLayer = L.layerGroup() this.polylinesLayer = L.layerGroup() this.photoMarkers = L.layerGroup() // Set default center and zoom for world view - const hasValidCoordinates = this.coordinates && Array.isArray(this.coordinates) && this.coordinates.length > 0 - const center = hasValidCoordinates - ? [this.coordinates[0][0], this.coordinates[0][1]] - : [20, 0] // Roughly centers the world map - const zoom = hasValidCoordinates ? 14 : 2 + const center = [20, 0] // Roughly centers the world map + const zoom = 2 // Initialize map this.map = L.map(this.containerTarget).setView(center, zoom) @@ -72,7 +68,6 @@ export default class extends Controller { }).addTo(this.map) const overlayMaps = { - "Points": this.markersLayer, "Route": this.polylinesLayer, "Photos": this.photoMarkers } @@ -116,6 +111,27 @@ export default class extends Controller { this.addPolyline() this.fitMapToBounds() } + + // After map initialization, add the path if it exists + if (this.containerTarget.dataset.path) { + const pathData = this.containerTarget.dataset.path.replace(/^"|"$/g, ''); // Remove surrounding quotes + const coordinates = this.parseLineString(pathData); + + const polyline = L.polyline(coordinates, { + color: 'blue', + opacity: 0.8, + weight: 3, + zIndexOffset: 400 + }); + + polyline.addTo(this.polylinesLayer); + this.polylinesLayer.addTo(this.map); + + // Fit the map to the polyline bounds + if (coordinates.length > 0) { + this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] }); + } + } } disconnect() { @@ -153,9 +169,6 @@ export default class extends Controller { const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit) marker.bindPopup(popupContent) - - // Add to markers layer instead of directly to map - marker.addTo(this.markersLayer) }) } @@ -191,7 +204,6 @@ export default class extends Controller { ]).sort((a, b) => a[4] - b[4]); // Clear existing layers - this.markersLayer.clearLayers() this.polylinesLayer.clearLayers() this.photoMarkers.clearLayers() @@ -202,4 +214,17 @@ export default class extends Controller { this.fitMapToBounds() } } + + // Add this method to parse the LineString format + parseLineString(lineString) { + // Remove LINESTRING and parentheses, then split into coordinate pairs + const coordsString = lineString.replace('LINESTRING (', '').replace(')', ''); + const coords = coordsString.split(', '); + + // Convert each coordinate pair to [lat, lng] format + return coords.map(coord => { + const [lng, lat] = coord.split(' ').map(Number); + return [lat, lng]; // Swap to lat, lng for Leaflet + }); + } } diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb index 9d6dc4ee..44a4ce53 100644 --- a/app/views/trips/show.html.erb +++ b/app/views/trips/show.html.erb @@ -24,7 +24,6 @@ data-distance_unit="<%= DISTANCE_UNIT %>" data-api_key="<%= current_user.api_key %>" data-user_settings="<%= current_user.settings.to_json %>" - data-coordinates="<%= @coordinates.to_json %>" data-path="<%= @trip.path.to_json %>" data-timezone="<%= Rails.configuration.time_zone %>">
From 380dd9235d711db77ae61d3fa4964f8e244dd486 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 15:03:57 +0100 Subject: [PATCH 050/157] Calculate path and distance before saving trip --- app/assets/stylesheets/actiontext.css | 3 ++- app/javascript/controllers/datetime_controller.js | 4 ++++ app/javascript/controllers/trips_controller.js | 7 ++++++- app/models/trip.rb | 7 ++++--- app/views/trips/_form.html.erb | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/actiontext.css b/app/assets/stylesheets/actiontext.css index b849676e..ae5522ab 100644 --- a/app/assets/stylesheets/actiontext.css +++ b/app/assets/stylesheets/actiontext.css @@ -40,6 +40,7 @@ background-color: white !important; } -.trix-content { +.trix-content-editor { min-height: 10rem; + width: 100%; } diff --git a/app/javascript/controllers/datetime_controller.js b/app/javascript/controllers/datetime_controller.js index 04c9061b..b56f07e3 100644 --- a/app/javascript/controllers/datetime_controller.js +++ b/app/javascript/controllers/datetime_controller.js @@ -1,3 +1,7 @@ +// This controller is being used on: +// - trips/new +// - trips/edit + import { Controller } from "@hotwired/stimulus" export default class extends Controller { diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index c40b1426..1d2e8c7e 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -1,3 +1,8 @@ +// This controller is being used on: +// - trips/show +// - trips/edit +// - trips/new + import { Controller } from "@hotwired/stimulus" import L from "leaflet" import { @@ -192,7 +197,7 @@ export default class extends Controller { this.map.fitBounds(bounds, { padding: [50, 50] }) } - // Add this new method to update coordinates and refresh the map + // Update coordinates and refresh the map updateMapWithCoordinates(newCoordinates) { // Transform the coordinates to match the expected format this.coordinates = newCoordinates.map(point => [ diff --git a/app/models/trip.rb b/app/models/trip.rb index 00c2774a..4043e8c7 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -7,12 +7,13 @@ class Trip < ApplicationRecord validates :name, :started_at, :ended_at, presence: true - before_save :calculate_distance + before_save :create_path! def create_path! - self.path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + trip_path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + distance = calculate_distance - save! + update_columns(path: trip_path, distance: distance) # Avoids recursion with `after_save` end def points diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb index cf5518ff..40dc57e3 100644 --- a/app/views/trips/_form.html.erb +++ b/app/views/trips/_form.html.erb @@ -62,7 +62,7 @@
<%= form.label :notes %> - <%= form.rich_text_area :notes %> + <%= form.rich_text_area :notes, class: 'trix-content-editor' %>
From 401ac8ca31e7acc677b9c38cd4510fac276e4a22 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 15:19:05 +0100 Subject: [PATCH 051/157] Use path instead of coordinates on trips#index --- .../controllers/trip_map_controller.js | 76 +++++++++++++++---- app/views/trips/_trip.html.erb | 2 +- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/app/javascript/controllers/trip_map_controller.js b/app/javascript/controllers/trip_map_controller.js index b2a18bfb..1bbdc207 100644 --- a/app/javascript/controllers/trip_map_controller.js +++ b/app/javascript/controllers/trip_map_controller.js @@ -1,10 +1,13 @@ +// This controller is being used on: +// - trips/index + import { Controller } from "@hotwired/stimulus" import L from "leaflet" export default class extends Controller { static values = { tripId: Number, - coordinates: Array, + path: String, apiKey: String, userSettings: Object, timezone: String, @@ -12,6 +15,8 @@ export default class extends Controller { } connect() { + console.log("TripMap controller connected") + setTimeout(() => { this.initializeMap() }, 100) @@ -23,7 +28,7 @@ export default class extends Controller { zoomControl: false, dragging: false, scrollWheelZoom: false, - attributionControl: true // Disable default attribution control + attributionControl: true }) // Add the tile layer @@ -33,24 +38,69 @@ export default class extends Controller { }).addTo(this.map) // If we have coordinates, show the route - if (this.hasCoordinatesValue && this.coordinatesValue.length > 0) { + if (this.hasPathValue && this.pathValue) { this.showRoute() + } else { + console.log("No path value available") } } showRoute() { - const points = this.coordinatesValue.map(coord => [coord[0], coord[1]]) + const points = this.parseLineString(this.pathValue) - const polyline = L.polyline(points, { - color: 'blue', - opacity: 0.8, - weight: 3, - zIndexOffset: 400 - }).addTo(this.map) + // Only create polyline if we have points + if (points.length > 0) { + const polyline = L.polyline(points, { + color: 'blue', + opacity: 0.8, + weight: 3, + zIndexOffset: 400 + }) - this.map.fitBounds(polyline.getBounds(), { - padding: [20, 20] - }) + // Add the polyline to the map + polyline.addTo(this.map) + + // Fit the map bounds + this.map.fitBounds(polyline.getBounds(), { + padding: [20, 20] + }) + } else { + console.error("No valid points to create polyline") + } + } + + parseLineString(linestring) { + try { + // Remove 'LINESTRING (' from start and ')' from end + const coordsString = linestring + .replace(/LINESTRING\s*\(/, '') // Remove LINESTRING and opening parenthesis + .replace(/\)$/, '') // Remove closing parenthesis + .trim() // Remove any leading/trailing whitespace + + // Split into coordinate pairs and parse + const points = coordsString.split(',').map(pair => { + // Clean up any extra whitespace and remove any special characters + const cleanPair = pair.trim().replace(/[()"\s]+/g, ' ') + const [lng, lat] = cleanPair.split(' ').filter(Boolean).map(Number) + + // Validate the coordinates + if (isNaN(lat) || isNaN(lng) || !lat || !lng) { + console.error("Invalid coordinates:", cleanPair) + return null + } + + return [lat, lng] // Leaflet uses [lat, lng] order + }).filter(point => point !== null) // Remove any invalid points + + // Validate we have points before returning + if (points.length === 0) { + return [] + } + + return points + } catch (error) { + return [] + } } disconnect() { diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb index e0b14ba8..f7a198b6 100644 --- a/app/views/trips/_trip.html.erb +++ b/app/views/trips/_trip.html.erb @@ -13,7 +13,7 @@ class="rounded-lg z-0" data-controller="trip-map" data-trip-map-trip-id-value="<%= trip.id %>" - data-trip-map-coordinates-value="<%= trip.points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country).to_json %>" + data-trip-map-path-value="<%= trip.path.to_json %>" data-trip-map-api-key-value="<%= current_user.api_key %>" data-trip-map-user-settings-value="<%= current_user.settings.to_json %>" data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>" From 9c102c1de8ccc6278e9e63341c2b581cd27f6777 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 15:22:46 +0100 Subject: [PATCH 052/157] Fix rendering polyline on trip editing page --- app/views/trips/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb index 40dc57e3..b70c5704 100644 --- a/app/views/trips/_form.html.erb +++ b/app/views/trips/_form.html.erb @@ -20,7 +20,7 @@ data-distance_unit="<%= DISTANCE_UNIT %>" data-api_key="<%= current_user.api_key %>" data-user_settings="<%= current_user.settings.to_json %>" - data-coordinates="<%= @coordinates.to_json %>" + data-path="<%= trip.path.to_json %>" data-timezone="<%= Rails.configuration.time_zone %>">
From 6e9c981329794a0486a9afedc5868bd9409f5812 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 15:35:35 +0100 Subject: [PATCH 053/157] Fix photos fetching with trip dates --- .../controllers/trips_controller.js | 36 +++++++++++++++---- app/views/trips/_form.html.erb | 2 ++ app/views/trips/show.html.erb | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 1d2e8c7e..974feb30 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -43,7 +43,6 @@ export default class extends Controller { // Add event listener for coordinates updates this.element.addEventListener('coordinates-updated', (event) => { - console.log("Coordinates updated:", event.detail.coordinates) this.updateMapWithCoordinates(event.detail.coordinates) }) } @@ -84,6 +83,15 @@ export default class extends Controller { this.map.on('overlayadd', (e) => { if (e.name !== 'Photos') return; + const startedAt = this.element.dataset.started_at; + const endedAt = this.element.dataset.ended_at; + + console.log('Dataset values:', { + startedAt, + endedAt, + path: this.element.dataset.path + }); + if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) { showFlashMessage( 'error', @@ -92,13 +100,26 @@ export default class extends Controller { return; } - if (!this.coordinates?.length) return; + // Try to get dates from coordinates first, then fall back to path data + let startDate, endDate; - const firstCoord = this.coordinates[0]; - const lastCoord = this.coordinates[this.coordinates.length - 1]; - - const startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0]; - const endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0]; + if (this.coordinates?.length) { + const firstCoord = this.coordinates[0]; + const lastCoord = this.coordinates[this.coordinates.length - 1]; + startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0]; + endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0]; + } else if (startedAt && endedAt) { + // Parse the dates and format them correctly + startDate = new Date(startedAt).toISOString().split('T')[0]; + endDate = new Date(endedAt).toISOString().split('T')[0]; + } else { + console.log('No date range available for photos'); + showFlashMessage( + 'error', + 'No date range available for photos. Please ensure the trip has start and end dates.' + ); + return; + } fetchAndDisplayPhotos({ map: this.map, @@ -174,6 +195,7 @@ export default class extends Controller { const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit) marker.bindPopup(popupContent) + marker.addTo(this.polylinesLayer) }) } diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb index b70c5704..847c2df2 100644 --- a/app/views/trips/_form.html.erb +++ b/app/views/trips/_form.html.erb @@ -21,6 +21,8 @@ data-api_key="<%= current_user.api_key %>" data-user_settings="<%= current_user.settings.to_json %>" data-path="<%= trip.path.to_json %>" + data-started_at="<%= trip.started_at %>" + data-ended_at="<%= trip.ended_at %>" data-timezone="<%= Rails.configuration.time_zone %>"> diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb index 44a4ce53..f4709aa5 100644 --- a/app/views/trips/show.html.erb +++ b/app/views/trips/show.html.erb @@ -25,6 +25,8 @@ data-api_key="<%= current_user.api_key %>" data-user_settings="<%= current_user.settings.to_json %>" data-path="<%= @trip.path.to_json %>" + data-started_at="<%= @trip.started_at %>" + data-ended_at="<%= @trip.ended_at %>" data-timezone="<%= Rails.configuration.time_zone %>">
From 01275d0d2e39490f452bd02876ea19589b474065 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 15:58:44 +0100 Subject: [PATCH 054/157] Add some tests --- app/models/trip.rb | 4 +-- .../20250123151849_create_paths_for_trips.rb | 3 ++ spec/factories/trips.rb | 2 ++ spec/jobs/trips/create_path_job_spec.rb | 20 ++++++++++- spec/models/trip_spec.rb | 4 +++ spec/services/tracks/build_path_spec.rb | 35 +++++++++++++++++++ 6 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 spec/services/tracks/build_path_spec.rb diff --git a/app/models/trip.rb b/app/models/trip.rb index 4043e8c7..cd4f7225 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -11,9 +11,9 @@ class Trip < ApplicationRecord def create_path! trip_path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call - distance = calculate_distance - update_columns(path: trip_path, distance: distance) # Avoids recursion with `after_save` + self.distance = calculate_distance + self.path = trip_path end def points diff --git a/db/data/20250123151849_create_paths_for_trips.rb b/db/data/20250123151849_create_paths_for_trips.rb index 6abcfff4..c78cffff 100644 --- a/db/data/20250123151849_create_paths_for_trips.rb +++ b/db/data/20250123151849_create_paths_for_trips.rb @@ -2,6 +2,9 @@ class CreatePathsForTrips < ActiveRecord::Migration[8.0] def up + Trip.find_each do |trip| + Trips::CreatePathJob.perform_later(trip.id) + end end def down diff --git a/spec/factories/trips.rb b/spec/factories/trips.rb index 4ef4041a..5986e882 100644 --- a/spec/factories/trips.rb +++ b/spec/factories/trips.rb @@ -7,6 +7,8 @@ FactoryBot.define do started_at { DateTime.new(2024, 11, 27, 17, 16, 21) } ended_at { DateTime.new(2024, 11, 29, 17, 16, 21) } notes { FFaker::Lorem.sentence } + distance { 100 } + path { 'LINESTRING(1 1, 2 2, 3 3)' } trait :with_points do after(:build) do |trip| diff --git a/spec/jobs/trips/create_path_job_spec.rb b/spec/jobs/trips/create_path_job_spec.rb index 1dd711ef..60d288e3 100644 --- a/spec/jobs/trips/create_path_job_spec.rb +++ b/spec/jobs/trips/create_path_job_spec.rb @@ -1,5 +1,23 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Trips::CreatePathJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" + let!(:trip) { create(:trip, :with_points) } + let(:points) { trip.points } + let(:trip_path) do + "LINESTRING (#{points.map do |point| + "#{point.longitude.to_f.round(5)} #{point.latitude.to_f.round(5)}" + end.join(', ')})" + end + + before do + trip.update(path: nil, distance: nil) + end + + it 'creates a path for a trip' do + described_class.perform_now(trip.id) + + expect(trip.reload.path.to_s).to eq(trip_path) + end end diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index 032185bd..f56daf20 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -21,6 +21,10 @@ RSpec.describe Trip, type: :model do it 'sets the distance' do expect(trip.distance).to eq(calculated_distance) end + + it 'sets the path' do + expect(trip.path).to be_present + end end describe '#countries' do diff --git a/spec/services/tracks/build_path_spec.rb b/spec/services/tracks/build_path_spec.rb new file mode 100644 index 00000000..1d2db10a --- /dev/null +++ b/spec/services/tracks/build_path_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::BuildPath do + describe '#call' do + let(:coordinates) do + [ + [45.123456, -122.654321], # [lat, lng] + [45.234567, -122.765432], + [45.345678, -122.876543] + ] + end + + let(:service) { described_class.new(coordinates) } + let(:result) { service.call } + + it 'returns an RGeo::Geographic::SphericalLineString' do + expect(result).to be_a(RGeo::Geographic::SphericalLineStringImpl) + end + + it 'creates a line string with the correct number of points' do + expect(result.num_points).to eq(coordinates.length) + end + + it 'correctly converts coordinates to points with rounded values' do + points = result.points + + coordinates.each_with_index do |(lat, lng), index| + expect(points[index].x).to eq(lng.to_f.round(5)) + expect(points[index].y).to eq(lat.to_f.round(5)) + end + end + end +end From 5bd6a6c072935d6392e270ff5283b660307f8392 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 24 Jan 2025 16:37:28 +0100 Subject: [PATCH 055/157] Don't trim time from start and end dates --- CHANGELOG.md | 4 ++++ app/javascript/controllers/maps_controller.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e66d12ac..95de7bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Trips are now store their paths in the database independently of the points. - Trips are now being rendered on the map using their precalculated paths instead of list of coordinates. +### Changed + +- Requesting photos on the Map page now uses the start and end dates from the URL params. #589 + # 0.23.5 - 2025-01-22 ### Added diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 313b477d..997821da 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -218,8 +218,8 @@ export default class extends Controller { } const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0]; - const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0]; + const startDate = urlParams.get('start_at') || new Date().toISOString(); + const endDate = urlParams.get('end_at')|| new Date().toISOString(); await fetchAndDisplayPhotos({ map: this.map, photoMarkers: this.photoMarkers, From e5ee29617ea68735be4ad254952728d64da44550 Mon Sep 17 00:00:00 2001 From: Arne Schwarck Date: Sat, 25 Jan 2025 21:55:25 +0100 Subject: [PATCH 056/157] Add radius param of 10 https://github.com/Freika/dawarich/discussions/652 --- app/services/reverse_geocoding/places/fetch_data.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 9eec9de4..17a0dad0 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -96,7 +96,7 @@ class ReverseGeocoding::Places::FetchData end def reverse_geocoded_places - data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true) + data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true, params: { radius: 10 }) data.reject do |place| place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) || From 908232d397d16d955fea85e87895fbf81ae56b70 Mon Sep 17 00:00:00 2001 From: Arne Schwarck Date: Sat, 25 Jan 2025 22:11:35 +0100 Subject: [PATCH 057/157] Fix reverse geocoding issue Previously, reverse geocoding queries in the Photon lookup did not properly limit results within a specified search radius, leading to inaccurate or unexpected locations being returned. This fix ensures that the :radius parameter is passed directly, just like :limit and :distance_sort, instead of being nested under :params. By aligning with the Photon lookup implementation in Geocoder, this change improves accuracy and ensures that results are correctly filtered based on proximity, resolving issues where points were incorrectly matched due to missing radius constraints. Resolves: Reverse geocoding mismatch for close proximity queries --- app/services/reverse_geocoding/places/fetch_data.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 17a0dad0..12186c9f 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -96,7 +96,7 @@ class ReverseGeocoding::Places::FetchData end def reverse_geocoded_places - data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true, params: { radius: 10 }) + data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true, radius: 10) data.reject do |place| place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) || From 4e1cbda341a2c90289c31aa31a5b0cf866aeafb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:33:14 +0000 Subject: [PATCH 058/157] Bump rubocop-rails from 2.29.0 to 2.29.1 Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.29.0 to 2.29.1. - [Release notes](https://github.com/rubocop/rubocop-rails/releases) - [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.29.0...v2.29.1) --- updated-dependencies: - dependency-name: rubocop-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 43f74521..50976765 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,7 +181,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.4) logger (1.6.5) lograge (0.14.0) actionpack (>= 4) @@ -342,7 +342,7 @@ GEM rswag-ui (2.16.0) actionpack (>= 5.2, < 8.1) railties (>= 5.2, < 8.1) - rubocop (1.70.0) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -354,7 +354,7 @@ GEM unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.37.0) parser (>= 3.3.1.0) - rubocop-rails (2.29.0) + rubocop-rails (2.29.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) From f1fbb9acac6a96242436636858ea3f0ec3b6a00d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:33:26 +0000 Subject: [PATCH 059/157] Bump database_consistency from 2.0.0 to 2.0.3 Bumps [database_consistency](https://github.com/djezzzl/database_consistency) from 2.0.0 to 2.0.3. - [Changelog](https://github.com/djezzzl/database_consistency/blob/master/CHANGELOG.md) - [Commits](https://github.com/djezzzl/database_consistency/compare/v2.0.0...v2.0.3) --- updated-dependencies: - dependency-name: database_consistency dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 43f74521..1186f8ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,7 +109,7 @@ GEM data_migrate (11.2.0) activerecord (>= 6.1) railties (>= 6.1) - database_consistency (2.0.0) + database_consistency (2.0.3) activerecord (>= 3.2) date (3.4.1) debug (1.10.0) @@ -410,7 +410,7 @@ GEM tailwindcss-ruby (3.4.17-x86_64-darwin) tailwindcss-ruby (3.4.17-x86_64-linux) thor (1.3.2) - timeout (0.4.2) + timeout (0.4.3) turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) From 7a5c7a8c1c29289972fb4dac936e38ba12ae6732 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:34:03 +0000 Subject: [PATCH 060/157] Bump sidekiq from 7.3.7 to 7.3.8 Bumps [sidekiq](https://github.com/sidekiq/sidekiq) from 7.3.7 to 7.3.8. - [Changelog](https://github.com/sidekiq/sidekiq/blob/main/Changes.md) - [Commits](https://github.com/sidekiq/sidekiq/compare/v7.3.7...v7.3.8) --- updated-dependencies: - dependency-name: sidekiq dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 43f74521..6e54ca85 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -366,7 +366,8 @@ GEM shrine (3.6.0) content_disposition (~> 1.0) down (~> 5.1) - sidekiq (7.3.7) + sidekiq (7.3.8) + base64 connection_pool (>= 2.3.0) logger rack (>= 2.2.4) From e210fb89cf226fb888460439b58a033184566f34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:34:20 +0000 Subject: [PATCH 061/157] Bump tailwindcss-rails from 3.3.0 to 3.3.1 Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.3.0 to 3.3.1. - [Release notes](https://github.com/rails/tailwindcss-rails/releases) - [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md) - [Commits](https://github.com/rails/tailwindcss-rails/compare/v3.3.0...v3.3.1) --- updated-dependencies: - dependency-name: tailwindcss-rails dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 43f74521..b45a5b8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -163,7 +163,8 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.0) - irb (1.14.3) + irb (1.15.1) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.9.1) @@ -240,6 +241,9 @@ GEM patience_diff (1.2.0) optimist (~> 3.0) pg (1.5.9) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) prometheus_exporter (2.2.0) webrick pry (0.14.2) @@ -400,9 +404,9 @@ GEM attr_extras (>= 6.2.4) diff-lcs patience_diff - tailwindcss-rails (3.3.0) + tailwindcss-rails (3.3.1) railties (>= 7.0.0) - tailwindcss-ruby + tailwindcss-ruby (~> 3.0) tailwindcss-ruby (3.4.17) tailwindcss-ruby (3.4.17-aarch64-linux) tailwindcss-ruby (3.4.17-arm-linux) From fd47bf7d5dca6f84e0c1f6850df14f67d7a470c8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 11:43:02 +0100 Subject: [PATCH 062/157] Update trip path calculation --- CONTRIBUTING.md | 2 +- README.md | 2 +- app/jobs/trips/create_path_job.rb | 5 ++++- app/models/trip.rb | 18 ++++++++++++------ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00214a48..d1470f1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ #### **Did you write a patch that fixes a bug?** -* Open a new GitHub pull request with the patch. +* Open a new GitHub pull request with the patch against the `dev` branch. * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. diff --git a/README.md b/README.md index a087aab4..0d21ed03 100644 --- a/README.md +++ b/README.md @@ -28,7 +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. --- ## ⚠️ Disclaimer diff --git a/app/jobs/trips/create_path_job.rb b/app/jobs/trips/create_path_job.rb index f36fa7cd..d64a39ec 100644 --- a/app/jobs/trips/create_path_job.rb +++ b/app/jobs/trips/create_path_job.rb @@ -5,6 +5,9 @@ class Trips::CreatePathJob < ApplicationJob def perform(trip_id) trip = Trip.find(trip_id) - trip.create_path! + + trip.calculate_path_and_distance + + trip.save! end end diff --git a/app/models/trip.rb b/app/models/trip.rb index cd4f7225..5e094078 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -7,15 +7,14 @@ class Trip < ApplicationRecord validates :name, :started_at, :ended_at, presence: true - before_save :create_path! + before_save :calculate_path_and_distance - def create_path! - trip_path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call - - self.distance = calculate_distance - self.path = trip_path + def calculate_path_and_distance + calculate_path + calculate_distance end + def points user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) end @@ -47,6 +46,13 @@ class Trip < ApplicationRecord vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos end + def calculate_path + trip_path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + + self.path = trip_path + end + + def calculate_distance distance = 0 From 5913b65ca84770437359f92500c3e288647e9aea Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 11:46:41 +0100 Subject: [PATCH 063/157] Update CircleCI config --- .circleci/config.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d0055f31..df09558f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,11 +10,15 @@ jobs: - image: cimg/ruby:3.3.4 environment: RAILS_ENV: test - - image: cimg/postgres:13.3 + - image: cimg/postgres:14.0 environment: POSTGRES_USER: postgres POSTGRES_DB: test_database POSTGRES_PASSWORD: mysecretpassword + POSTGRES_INITDB_ARGS: --enable-debug --data-checksums --encoding=UTF8 --lc-collate=C --lc-ctype=C + command: | + apt-get update + apt-get install -y postgis postgresql-14-postgis-3 - image: redis:7.0 steps: From 20d38625488f0c84295081507637a026f903971e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 11:49:15 +0100 Subject: [PATCH 064/157] Update database config for CI --- config/database.ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/database.ci.yml b/config/database.ci.yml index c5ee5c9d..d5e13575 100644 --- a/config/database.ci.yml +++ b/config/database.ci.yml @@ -1,8 +1,9 @@ # config/database.ci.yml test: - adapter: postgresql + adapter: postgis encoding: unicode pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + host: localhost database: <%= ENV["POSTGRES_DB"] %> username: <%= ENV['POSTGRES_USER'] %> password: <%= ENV["POSTGRES_PASSWORD"] %> From cb9e11c18a6d1ba3cde685fab1c80936b02e555e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 11:53:02 +0100 Subject: [PATCH 065/157] Update CircleCI config --- .circleci/config.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index df09558f..210c03cb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,10 @@ jobs: - image: cimg/ruby:3.3.4 environment: RAILS_ENV: test + POSTGRES_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_DB: test_database - image: cimg/postgres:14.0 environment: POSTGRES_USER: postgres @@ -23,17 +27,28 @@ jobs: steps: - checkout + - run: + name: Install System Dependencies + command: | + sudo apt-get update + sudo apt-get install -y postgresql-client + wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz + sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz + rm dockerize-linux-amd64-v0.6.1.tar.gz - run: name: Install Bundler command: gem install bundler - run: name: Bundle Install command: bundle install --jobs=4 --retry=3 + - run: + name: Wait for PostgreSQL + command: dockerize -wait tcp://localhost:5432 -timeout 1m - run: name: Database Setup command: | - bundle exec rails db:create - bundle exec rails db:schema:load + cp config/database.ci.yml config/database.yml + bundle exec rails db:create db:schema:load - run: name: Run RSpec tests command: bundle exec rspec From 3139d2897130439de19fca890f5dacc70e006896 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 11:55:33 +0100 Subject: [PATCH 066/157] Update CircleCI config --- .circleci/config.yml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 210c03cb..bd38b09c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,11 +10,7 @@ jobs: - image: cimg/ruby:3.3.4 environment: RAILS_ENV: test - POSTGRES_HOST: localhost - POSTGRES_USER: postgres - POSTGRES_PASSWORD: mysecretpassword - POSTGRES_DB: test_database - - image: cimg/postgres:14.0 + - image: cimg/postgres:14.0-postgis environment: POSTGRES_USER: postgres POSTGRES_DB: test_database @@ -27,28 +23,17 @@ jobs: steps: - checkout - - run: - name: Install System Dependencies - command: | - sudo apt-get update - sudo apt-get install -y postgresql-client - wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz - sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz - rm dockerize-linux-amd64-v0.6.1.tar.gz - run: name: Install Bundler command: gem install bundler - run: name: Bundle Install command: bundle install --jobs=4 --retry=3 - - run: - name: Wait for PostgreSQL - command: dockerize -wait tcp://localhost:5432 -timeout 1m - run: name: Database Setup command: | - cp config/database.ci.yml config/database.yml - bundle exec rails db:create db:schema:load + bundle exec rails db:create + bundle exec rails db:schema:load - run: name: Run RSpec tests command: bundle exec rspec From e99e105ab8f95fb47e77e2309e734762ba5d5a32 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 11:57:53 +0100 Subject: [PATCH 067/157] Update CircleCI config --- .circleci/config.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bd38b09c..460be1ea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,15 +10,11 @@ jobs: - image: cimg/ruby:3.3.4 environment: RAILS_ENV: test - - image: cimg/postgres:14.0-postgis + - image: cimg/postgres:13.3-postgis environment: POSTGRES_USER: postgres POSTGRES_DB: test_database POSTGRES_PASSWORD: mysecretpassword - POSTGRES_INITDB_ARGS: --enable-debug --data-checksums --encoding=UTF8 --lc-collate=C --lc-ctype=C - command: | - apt-get update - apt-get install -y postgis postgresql-14-postgis-3 - image: redis:7.0 steps: From 8a309a2186185580a280689672726b11cb0d889f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Jan 2025 12:18:03 +0100 Subject: [PATCH 068/157] Remove tracks --- CHANGELOG.md | 2 +- app/models/track.rb | 7 ------- app/models/user.rb | 1 - db/migrate/20250123145954_create_tracks.rb | 14 -------------- db/schema.rb | 11 ----------- spec/factories/tracks.rb | 10 ---------- spec/models/track_spec.rb | 15 --------------- spec/models/user_spec.rb | 1 - 8 files changed, 1 insertion(+), 60 deletions(-) delete mode 100644 app/models/track.rb delete mode 100644 db/migrate/20250123145954_create_tracks.rb delete mode 100644 spec/factories/tracks.rb delete mode 100644 spec/models/track_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 95de7bbf..1dd4398c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,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.23.6 - 2025-01-23 +# 0.23.6 - 2025-01-29 ### Added diff --git a/app/models/track.rb b/app/models/track.rb deleted file mode 100644 index 41e673b4..00000000 --- a/app/models/track.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class Track < ApplicationRecord - belongs_to :user - - validates :path, :started_at, :ended_at, presence: true -end diff --git a/app/models/user.rb b/app/models/user.rb index 90ff2fb0..b8d27f17 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,6 @@ class User < ApplicationRecord has_many :points, through: :imports has_many :places, through: :visits has_many :trips, dependent: :destroy - has_many :tracks, dependent: :destroy after_create :create_api_key before_save :strip_trailing_slashes diff --git a/db/migrate/20250123145954_create_tracks.rb b/db/migrate/20250123145954_create_tracks.rb deleted file mode 100644 index 35c6afa1..00000000 --- a/db/migrate/20250123145954_create_tracks.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class CreateTracks < ActiveRecord::Migration[8.0] - def change - create_table :tracks do |t| - t.datetime :started_at, null: false - t.datetime :ended_at, null: false - t.references :user, null: false, foreign_key: true - t.line_string :path, srid: 3857, null: false - - t.timestamps - end - end -end diff --git a/db/schema.rb b/db/schema.rb index 7e9cca52..b431351f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -193,16 +193,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do t.index ["year"], name: "index_stats_on_year" end - create_table "tracks", force: :cascade do |t| - t.datetime "started_at", null: false - t.datetime "ended_at", null: false - t.bigint "user_id", null: false - t.geometry "path", limit: {:srid=>3857, :type=>"line_string"}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["user_id"], name: "index_tracks_on_user_id" - end - create_table "trips", force: :cascade do |t| t.string "name", null: false t.datetime "started_at", null: false @@ -264,7 +254,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do add_foreign_key "points", "users" add_foreign_key "points", "visits" add_foreign_key "stats", "users" - add_foreign_key "tracks", "users" add_foreign_key "trips", "users" add_foreign_key "visits", "areas" add_foreign_key "visits", "places" diff --git a/spec/factories/tracks.rb b/spec/factories/tracks.rb deleted file mode 100644 index 32603460..00000000 --- a/spec/factories/tracks.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :track do - started_at { DateTime.new(2025, 1, 23, 15, 59, 55) } - ended_at { DateTime.new(2025, 1, 23, 16, 0, 0) } - user - path { 'LINESTRING(0 0, 1 1, 2 2)' } - end -end diff --git a/spec/models/track_spec.rb b/spec/models/track_spec.rb deleted file mode 100644 index 051b8ae8..00000000 --- a/spec/models/track_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Track, type: :model do - describe 'validations' do - it { is_expected.to validate_presence_of(:path) } - it { is_expected.to validate_presence_of(:started_at) } - it { is_expected.to validate_presence_of(:ended_at) } - end - - describe 'associations' do - it { is_expected.to belong_to(:user) } - end -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a9ce1d1e..398e436f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,7 +14,6 @@ RSpec.describe User, type: :model do it { is_expected.to have_many(:visits).dependent(:destroy) } it { is_expected.to have_many(:places).through(:visits) } it { is_expected.to have_many(:trips).dependent(:destroy) } - it { is_expected.to have_many(:tracks).dependent(:destroy) } end describe 'callbacks' do From 27714985de163f7bb421aad1c638faf2385e4579 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 1 Feb 2025 18:52:26 +0100 Subject: [PATCH 069/157] Change base image to slim --- Gemfile | 1 + Gemfile.lock | 1 + docker/Dockerfile.dev | 23 ++++++++++++++--------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Gemfile b/Gemfile index 592c2fd3..0a30ff39 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapt gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' +gem 'racc', '~> 1.8', '>= 1.8.1' # Nokogiri dependency gem 'rgeo' gem 'rswag-api' gem 'rswag-ui' diff --git a/Gemfile.lock b/Gemfile.lock index 8407dd3a..144f3b3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -482,6 +482,7 @@ DEPENDENCIES pry-rails puma pundit + racc (~> 1.8, >= 1.8.1) rails (~> 8.0) redis rgeo diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 37b04015..ba6a21fe 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM ruby:3.3.4-alpine +FROM ruby:3.3.4-slim ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 @@ -6,22 +6,27 @@ ENV BUNDLE_PATH=/usr/local/bundle/gems ENV RAILS_LOG_TO_STDOUT=true ENV RAILS_PORT=3000 ENV RAILS_ENV=development +ENV BUNDLE_FORCE_RUBY_PLATFORM=1 +ENV NOKOGIRI_USE_SYSTEM_LIBRARIES=1 # Install dependencies for application -RUN apk -U add --no-cache \ - build-base \ +RUN apt-get update -qq && apt-get install -y \ + build-essential \ git \ - postgresql-dev \ + libpq-dev \ postgresql-client \ + libxml2 \ libxml2-dev \ - libxslt-dev \ + libxslt1-dev \ nodejs \ - yarn \ + npm \ imagemagick \ tzdata \ less \ - yaml-dev \ - gcompat \ + libyaml-dev \ + pkg-config \ + && npm install -g yarn \ + && rm -rf /var/lib/apt/lists/* \ && mkdir -p $APP_PATH # Update gem system and install bundler @@ -35,7 +40,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ # Install all gems into the image RUN bundle config set --local path 'vendor/bundle' \ - && bundle install --jobs 4 --retry 3 \ + && bundle install --jobs 1 --retry 3 \ && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem # Copy the rest of the application From 8aa3e6818cba6b0dfbe2bc2318c57711674efd31 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 1 Feb 2025 19:49:57 +0100 Subject: [PATCH 070/157] Use dockerfile from 0.23.5 --- docker/Dockerfile.dev | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index ba6a21fe..37b04015 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM ruby:3.3.4-slim +FROM ruby:3.3.4-alpine ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 @@ -6,27 +6,22 @@ ENV BUNDLE_PATH=/usr/local/bundle/gems ENV RAILS_LOG_TO_STDOUT=true ENV RAILS_PORT=3000 ENV RAILS_ENV=development -ENV BUNDLE_FORCE_RUBY_PLATFORM=1 -ENV NOKOGIRI_USE_SYSTEM_LIBRARIES=1 # Install dependencies for application -RUN apt-get update -qq && apt-get install -y \ - build-essential \ +RUN apk -U add --no-cache \ + build-base \ git \ - libpq-dev \ + postgresql-dev \ postgresql-client \ - libxml2 \ libxml2-dev \ - libxslt1-dev \ + libxslt-dev \ nodejs \ - npm \ + yarn \ imagemagick \ tzdata \ less \ - libyaml-dev \ - pkg-config \ - && npm install -g yarn \ - && rm -rf /var/lib/apt/lists/* \ + yaml-dev \ + gcompat \ && mkdir -p $APP_PATH # Update gem system and install bundler @@ -40,7 +35,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ # Install all gems into the image RUN bundle config set --local path 'vendor/bundle' \ - && bundle install --jobs 1 --retry 3 \ + && bundle install --jobs 4 --retry 3 \ && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem # Copy the rest of the application From 8227c747f24b32ff0e070869603ffe5f29f7f061 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 1 Feb 2025 20:38:00 +0100 Subject: [PATCH 071/157] Revert older versions of actions --- .github/workflows/build_and_push.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 90c78ae1..198579b4 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -15,15 +15,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v2 with: ref: ${{ github.event.inputs.branch || github.ref_name }} - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v1 - name: Cache Docker layers uses: actions/cache@v4 @@ -61,7 +60,7 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v2 with: context: . file: ./docker/Dockerfile.dev From 73fc46b1ec48e49d955437dd4522a12d39e71fbc Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 15:04:24 +0100 Subject: [PATCH 072/157] Remove arm64 from platforms as an experiment --- .github/workflows/build_and_push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 198579b4..8b3adf89 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -66,6 +66,6 @@ jobs: file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From 5bc1ea3b3657a76a6bc670e0f919f3844031d331 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 15:48:16 +0100 Subject: [PATCH 073/157] Return arm64 to the build matrix --- .github/workflows/build_and_push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 8b3adf89..198579b4 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -66,6 +66,6 @@ jobs: file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/amd64,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From 6259c0c476029c14f80a6d907dce23e1f8fedf73 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 17:14:48 +0100 Subject: [PATCH 074/157] Update build and push action --- .github/workflows/build_and_push.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 198579b4..e51ed126 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -59,13 +59,24 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - - name: Build and push + - name: Build and push (arm64) uses: docker/build-push-action@v2 with: context: . file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 + platforms: linux/arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-arm64 + + - name: Build and push (other architectures) + uses: docker/build-push-action@v2 + with: + context: . + file: ./docker/Dockerfile.dev + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + platforms: linux/amd64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From f91e142a2f047626e6cc12ad167e616f1a9edcd5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 17:33:16 +0100 Subject: [PATCH 075/157] Use v6 of build and push action --- .github/workflows/build_and_push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index e51ed126..7269311a 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -60,7 +60,7 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Build and push (arm64) - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile.dev From 267c4271b5894daaf5a1a21f26a3f1ede5d9c0ab Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 21:10:15 +0100 Subject: [PATCH 076/157] Remove cache from build and push action --- .github/workflows/build_and_push.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 7269311a..63f963e4 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -67,8 +67,8 @@ jobs: push: true tags: ${{ steps.docker_meta.outputs.tags }} platforms: linux/arm64 - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-arm64 + # cache-from: type=local,src=/tmp/.buildx-cache + # cache-to: type=local,dest=/tmp/.buildx-cache-arm64 - name: Build and push (other architectures) uses: docker/build-push-action@v2 From ce472bd53a448208cb3fd23a654556e8feddd49e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 21:41:41 +0100 Subject: [PATCH 077/157] Update CircleCI config --- .circleci/config.yml | 67 ++++++++++++++++++++++++++++ .github/workflows/build_and_push.yml | 4 +- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 460be1ea..13d57942 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,6 +3,12 @@ version: 2.1 orbs: ruby: circleci/ruby@2.1.4 browser-tools: circleci/browser-tools@1.4.8 + docker: circleci/docker@2.4.0 + +parameters: + branch: + type: string + default: "master" jobs: test: @@ -36,7 +42,68 @@ jobs: - store_artifacts: path: coverage + build-and-push: + machine: + image: ubuntu-2204:current + steps: + - checkout + + - docker/setup-buildx + + - restore_cache: + keys: + - docker-layers-{{ .Branch }}-{{ .Revision }} + - docker-layers-{{ .Branch }} + - docker-layers- + + - run: + name: Install dependencies + command: npm install + + - run: + name: Login to DockerHub + command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + + - run: + name: Set Docker tags + command: | + VERSION=${CIRCLE_TAG:-latest} + TAGS="freikin/dawarich:${VERSION}" + + # Add :rc tag for pre-releases (assuming tag contains 'rc' for pre-releases) + if [[ $CIRCLE_TAG == *"rc"* ]]; then + TAGS="${TAGS},freikin/dawarich:rc" + fi + + # Add :latest tag only if not a pre-release + if [[ $CIRCLE_TAG != *"rc"* ]]; then + TAGS="${TAGS},freikin/dawarich:latest" + fi + + echo "export DOCKER_TAGS=${TAGS}" >> $BASH_ENV + + - docker/build: + image: freikin/dawarich + tag: ${DOCKER_TAGS} + dockerfile: ./docker/Dockerfile.dev + platform: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 + push: true + + - save_cache: + key: docker-layers-{{ .Branch }}-{{ .Revision }} + paths: + - /tmp/docker-cache + workflows: rspec: jobs: - test + version: 2 + build-and-deploy: + jobs: + - build-and-push: + filters: + tags: + only: /^v.*/ + branches: + only: master diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 63f963e4..7269311a 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -67,8 +67,8 @@ jobs: push: true tags: ${{ steps.docker_meta.outputs.tags }} platforms: linux/arm64 - # cache-from: type=local,src=/tmp/.buildx-cache - # cache-to: type=local,dest=/tmp/.buildx-cache-arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-arm64 - name: Build and push (other architectures) uses: docker/build-push-action@v2 From 4a9e8a083f93fec940770964a214636c2700f431 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 21:49:52 +0100 Subject: [PATCH 078/157] Update CircleCI config --- .circleci/config.yml | 106 ++++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 13d57942..11bb56cc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,14 +1,14 @@ version: 2.1 +executors: + docker-executor: + machine: + image: ubuntu-2204:current + orbs: ruby: circleci/ruby@2.1.4 browser-tools: circleci/browser-tools@1.4.8 - docker: circleci/docker@2.4.0 -parameters: - branch: - type: string - default: "master" jobs: test: @@ -41,69 +41,83 @@ jobs: command: bundle exec rspec - store_artifacts: path: coverage - build-and-push: - machine: - image: ubuntu-2204:current + executor: docker-executor + environment: + DOCKER_CLI_EXPERIMENTAL: enabled steps: - checkout - - docker/setup-buildx + - run: + name: Set branch variable + command: | + if [ -z "$CIRCLE_BRANCH" ]; then + echo 'export BUILD_BRANCH=master' >> $BASH_ENV + else + echo "export BUILD_BRANCH=$CIRCLE_BRANCH" >> $BASH_ENV + fi - - restore_cache: - keys: - - docker-layers-{{ .Branch }}-{{ .Revision }} - - docker-layers-{{ .Branch }} - - docker-layers- + - setup_remote_docker: + version: 20.10.24 + docker_layer_caching: true - run: - name: Install dependencies - command: npm install + name: Install dependencies + command: npm install - run: - name: Login to DockerHub - command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + name: Login to Docker Hub + command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - run: - name: Set Docker tags - command: | - VERSION=${CIRCLE_TAG:-latest} - TAGS="freikin/dawarich:${VERSION}" + name: Set Docker tags + command: | + if [[ -n "$CIRCLE_TAG" ]]; then + VERSION="${CIRCLE_TAG}" + else + VERSION="latest" + fi - # Add :rc tag for pre-releases (assuming tag contains 'rc' for pre-releases) - if [[ $CIRCLE_TAG == *"rc"* ]]; then - TAGS="${TAGS},freikin/dawarich:rc" - fi + TAGS="freikin/dawarich:${VERSION}" - # Add :latest tag only if not a pre-release - if [[ $CIRCLE_TAG != *"rc"* ]]; then - TAGS="${TAGS},freikin/dawarich:latest" - fi + if [[ "$CIRCLE_TAG" =~ rc ]]; then + TAGS="${TAGS},freikin/dawarich:rc" + else + TAGS="${TAGS},freikin/dawarich:latest" + fi - echo "export DOCKER_TAGS=${TAGS}" >> $BASH_ENV + echo "export DOCKER_TAGS=\"$TAGS\"" >> $BASH_ENV - - docker/build: - image: freikin/dawarich - tag: ${DOCKER_TAGS} - dockerfile: ./docker/Dockerfile.dev - platform: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 - push: true + - run: + name: Build and push (arm64) + command: | + docker buildx create --use + docker buildx build --platform linux/arm64 \ + --file ./docker/Dockerfile.dev \ + --tag freikin/dawarich:${VERSION} \ + --push . - - save_cache: - key: docker-layers-{{ .Branch }}-{{ .Revision }} - paths: - - /tmp/docker-cache + - run: + name: Build and push (other architectures) + command: | + docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm/v6 \ + --file ./docker/Dockerfile.dev \ + --tag freikin/dawarich:${VERSION} \ + --push . workflows: + version: 2 rspec: jobs: - test - version: 2 - build-and-deploy: + + build-and-push: jobs: + - test - build-and-push: filters: - tags: - only: /^v.*/ branches: - only: master + only: + - master + tags: + only: /^v.*/ # Run only on version tags From a616c1568ef25d60451e3663e8726a08025188e1 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 21:51:32 +0100 Subject: [PATCH 079/157] Update CircleCI config --- .circleci/config.yml | 90 ++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 11bb56cc..36692f3c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,6 @@ orbs: ruby: circleci/ruby@2.1.4 browser-tools: circleci/browser-tools@1.4.8 - jobs: test: docker: @@ -41,6 +40,7 @@ jobs: command: bundle exec rspec - store_artifacts: path: coverage + build-and-push: executor: docker-executor environment: @@ -49,72 +49,70 @@ jobs: - checkout - run: - name: Set branch variable - command: | - if [ -z "$CIRCLE_BRANCH" ]; then - echo 'export BUILD_BRANCH=master' >> $BASH_ENV - else - echo "export BUILD_BRANCH=$CIRCLE_BRANCH" >> $BASH_ENV - fi + name: Set branch variable + command: | + if [ -z "$CIRCLE_BRANCH" ]; then + echo 'export BUILD_BRANCH=master' >> $BASH_ENV + else + echo "export BUILD_BRANCH=$CIRCLE_BRANCH" >> $BASH_ENV + fi - setup_remote_docker: - version: 20.10.24 - docker_layer_caching: true + version: 20.10.24 + docker_layer_caching: true - run: - name: Install dependencies - command: npm install + name: Install dependencies + command: npm install - run: - name: Login to Docker Hub - command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + name: Login to Docker Hub + command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - run: - name: Set Docker tags - command: | - if [[ -n "$CIRCLE_TAG" ]]; then - VERSION="${CIRCLE_TAG}" - else - VERSION="latest" - fi + name: Set Docker tags + command: | + if [[ -n "$CIRCLE_TAG" ]]; then + VERSION="${CIRCLE_TAG}" + else + VERSION="latest" + fi - TAGS="freikin/dawarich:${VERSION}" + TAGS="freikin/dawarich:${VERSION}" - if [[ "$CIRCLE_TAG" =~ rc ]]; then - TAGS="${TAGS},freikin/dawarich:rc" - else - TAGS="${TAGS},freikin/dawarich:latest" - fi + if [[ "$CIRCLE_TAG" =~ rc ]]; then + TAGS="${TAGS},freikin/dawarich:rc" + else + TAGS="${TAGS},freikin/dawarich:latest" + fi - echo "export DOCKER_TAGS=\"$TAGS\"" >> $BASH_ENV + echo "export DOCKER_TAGS=\"$TAGS\"" >> $BASH_ENV - run: - name: Build and push (arm64) - command: | - docker buildx create --use - docker buildx build --platform linux/arm64 \ - --file ./docker/Dockerfile.dev \ - --tag freikin/dawarich:${VERSION} \ - --push . + name: Build and push (arm64) + command: | + docker buildx create --use + docker buildx build --platform linux/arm64 \ + --file ./docker/Dockerfile.dev \ + --tag freikin/dawarich:${VERSION} \ + --push . - run: - name: Build and push (other architectures) - command: | - docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm/v6 \ - --file ./docker/Dockerfile.dev \ - --tag freikin/dawarich:${VERSION} \ - --push . + name: Build and push (other architectures) + command: | + docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm/v6 \ + --file ./docker/Dockerfile.dev \ + --tag freikin/dawarich:${VERSION} \ + --push . workflows: version: 2 - rspec: - jobs: - - test - - build-and-push: + build-and-test: jobs: - test - build-and-push: + requires: + - test # Ensures build happens only if tests pass filters: branches: only: From 193f251e3972528b36a85299164fc8e6d351e9ec Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 21:59:24 +0100 Subject: [PATCH 080/157] Update CircleCI config --- .circleci/config.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 36692f3c..1a5cd713 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -112,10 +112,11 @@ workflows: - test - build-and-push: requires: - - test # Ensures build happens only if tests pass + - test filters: branches: only: - master tags: - only: /^v.*/ # Run only on version tags + only: + - /^\d+\.\d+\.\d+$/ # Matches tags like 0.23.6 From e725128e30c0828ba0852bdcf21f3152650db956 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:02:21 +0100 Subject: [PATCH 081/157] Update CircleCI config --- .circleci/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1a5cd713..651f278a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -118,5 +118,4 @@ workflows: only: - master tags: - only: - - /^\d+\.\d+\.\d+$/ # Matches tags like 0.23.6 + only: /.*/ # Match ANY tag name From fd5db13d4225178d44cf1cee2bc86ee9b04ca027 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:11:22 +0100 Subject: [PATCH 082/157] Update CircleCI config --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 651f278a..9aaa7f69 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -117,5 +117,6 @@ workflows: branches: only: - master + - dev tags: only: /.*/ # Match ANY tag name From 009c9d3995e70a15c77b83acb4b8ad709421c78d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:17:10 +0100 Subject: [PATCH 083/157] Update CircleCI config --- .circleci/config.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9aaa7f69..2cfbdaa9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -111,8 +111,6 @@ workflows: jobs: - test - build-and-push: - requires: - - test filters: branches: only: From d937b9fa344ddd58bc91babce6b872f0b4d7580a Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:18:41 +0100 Subject: [PATCH 084/157] Update CircleCI config --- .circleci/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2cfbdaa9..91d5ca00 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,7 +42,8 @@ jobs: path: coverage build-and-push: - executor: docker-executor + docker: + - image: cimg/base:current environment: DOCKER_CLI_EXPERIMENTAL: enabled steps: From a2d67d8e387e5f770f02ea01a0b1b972362cff7e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:23:20 +0100 Subject: [PATCH 085/157] Update CircleCI config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 91d5ca00..74d96c6a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -43,7 +43,7 @@ jobs: build-and-push: docker: - - image: cimg/base:current + - image: cimg/node:current environment: DOCKER_CLI_EXPERIMENTAL: enabled steps: From 8c8ec576c463208d13c69e756b78bd09dc1d3885 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:25:37 +0100 Subject: [PATCH 086/157] Update CircleCI config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 74d96c6a..0c7861d1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,7 +68,7 @@ jobs: - run: name: Login to Docker Hub - command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + command: echo "$DOCKERHUB_TOKEN" | docker login -username "$DOCKERHUB_USERNAME" --password-stdin - run: name: Set Docker tags From 7b0324710130e96c134de8f8f8b84b2c528d5cba Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:28:31 +0100 Subject: [PATCH 087/157] Update circle ci config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0c7861d1..74d96c6a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,7 +68,7 @@ jobs: - run: name: Login to Docker Hub - command: echo "$DOCKERHUB_TOKEN" | docker login -username "$DOCKERHUB_USERNAME" --password-stdin + command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - run: name: Set Docker tags From 75112f3dc49e2b5be8a61221440a41145ce09485 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:30:45 +0100 Subject: [PATCH 088/157] Update circle ci config --- .circleci/config.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 74d96c6a..015bf33b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,6 +8,7 @@ executors: orbs: ruby: circleci/ruby@2.1.4 browser-tools: circleci/browser-tools@1.4.8 + docker: circleci/docker@2.2.1 jobs: test: @@ -66,9 +67,9 @@ jobs: name: Install dependencies command: npm install - - run: - name: Login to Docker Hub - command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + - docker/login: + username: << pipeline.parameters.DOCKERHUB_USERNAME >> + password: << pipeline.parameters.DOCKERHUB_TOKEN >> - run: name: Set Docker tags From 005b74eb652c1e54e43a5e5f0de1a448dc80761a Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:35:25 +0100 Subject: [PATCH 089/157] Update circle ci config --- .circleci/config.yml | 91 +++++++++++++------------------------------- 1 file changed, 26 insertions(+), 65 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 015bf33b..420e9203 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ executors: orbs: ruby: circleci/ruby@2.1.4 browser-tools: circleci/browser-tools@1.4.8 - docker: circleci/docker@2.2.1 + docker: circleci/docker@2.8.2 jobs: test: @@ -43,69 +43,29 @@ jobs: path: coverage build-and-push: - docker: - - image: cimg/node:current - environment: - DOCKER_CLI_EXPERIMENTAL: enabled + executor: + name: docker/docker + # You can also specify a different image if required. + parameters: + tag: + type: string + default: "rc" steps: - checkout - - - run: - name: Set branch variable - command: | - if [ -z "$CIRCLE_BRANCH" ]; then - echo 'export BUILD_BRANCH=master' >> $BASH_ENV - else - echo "export BUILD_BRANCH=$CIRCLE_BRANCH" >> $BASH_ENV - fi - - - setup_remote_docker: - version: 20.10.24 - docker_layer_caching: true - - - run: - name: Install dependencies - command: npm install - + # Login to Docker Hub using the orb command. The orb expects environment + # variables DOCKERHUB_USERNAME and DOCKERHUB_PASSWORD (or DOCKERHUB_TOKEN). - docker/login: - username: << pipeline.parameters.DOCKERHUB_USERNAME >> - password: << pipeline.parameters.DOCKERHUB_TOKEN >> - - - run: - name: Set Docker tags - command: | - if [[ -n "$CIRCLE_TAG" ]]; then - VERSION="${CIRCLE_TAG}" - else - VERSION="latest" - fi - - TAGS="freikin/dawarich:${VERSION}" - - if [[ "$CIRCLE_TAG" =~ rc ]]; then - TAGS="${TAGS},freikin/dawarich:rc" - else - TAGS="${TAGS},freikin/dawarich:latest" - fi - - echo "export DOCKER_TAGS=\"$TAGS\"" >> $BASH_ENV - - - run: - name: Build and push (arm64) - command: | - docker buildx create --use - docker buildx build --platform linux/arm64 \ - --file ./docker/Dockerfile.dev \ - --tag freikin/dawarich:${VERSION} \ - --push . - - - run: - name: Build and push (other architectures) - command: | - docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm/v6 \ - --file ./docker/Dockerfile.dev \ - --tag freikin/dawarich:${VERSION} \ - --push . + username: "$DOCKERHUB_USERNAME" + password: "$DOCKERHUB_TOKEN" + # Build and publish the Docker image. + - docker/build-publish: + image: "freikin/dawarich:<< parameters.tag >>" + dockerfile: ./docker/Dockerfile.dev + # Set additional options if needed: + build-args: "" + extra-build-args: "--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6" + registry: "docker.io" + push: true workflows: version: 2 @@ -113,10 +73,11 @@ workflows: jobs: - test - build-and-push: + requires: + - test filters: branches: - only: - - master - - dev + ignore: /.*/ # Ignore branches; run only on tag builds (or adjust as needed) tags: - only: /.*/ # Match ANY tag name + only: /.*/ # Run for any tag; adjust regex if you want more specific tag matching + tag: "<< pipeline.parameters.tag >>" From 6b0c6e1ed0365359c2c8f74fcb91385af909458f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:38:06 +0100 Subject: [PATCH 090/157] Update circle ci config --- .circleci/config.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 420e9203..6e023a08 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,6 @@ jobs: POSTGRES_DB: test_database POSTGRES_PASSWORD: mysecretpassword - image: redis:7.0 - steps: - checkout - run: @@ -45,23 +44,18 @@ jobs: build-and-push: executor: name: docker/docker - # You can also specify a different image if required. parameters: tag: type: string default: "rc" steps: - checkout - # Login to Docker Hub using the orb command. The orb expects environment - # variables DOCKERHUB_USERNAME and DOCKERHUB_PASSWORD (or DOCKERHUB_TOKEN). - docker/login: username: "$DOCKERHUB_USERNAME" password: "$DOCKERHUB_TOKEN" - # Build and publish the Docker image. - docker/build-publish: image: "freikin/dawarich:<< parameters.tag >>" dockerfile: ./docker/Dockerfile.dev - # Set additional options if needed: build-args: "" extra-build-args: "--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6" registry: "docker.io" @@ -77,7 +71,6 @@ workflows: - test filters: branches: - ignore: /.*/ # Ignore branches; run only on tag builds (or adjust as needed) + ignore: /.*/ # Run only on tag builds tags: - only: /.*/ # Run for any tag; adjust regex if you want more specific tag matching - tag: "<< pipeline.parameters.tag >>" + only: /.*/ # Run for any tag From e512b415803670a81f2d3ed2e0f901e2e1f24211 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:38:42 +0100 Subject: [PATCH 091/157] Update circle ci config --- .circleci/config.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e023a08..a2db6515 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,10 @@ version: 2.1 +parameters: + tag: + type: string + default: "rc" + executors: docker-executor: machine: @@ -74,3 +79,4 @@ workflows: ignore: /.*/ # Run only on tag builds tags: only: /.*/ # Run for any tag + tag: "<< pipeline.parameters.tag >>" From ac687f81240b190cba9a3d0b444e17fb1212d2aa Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:40:58 +0100 Subject: [PATCH 092/157] Update circle ci config --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a2db6515..4a46e64d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -56,8 +56,8 @@ jobs: steps: - checkout - docker/login: - username: "$DOCKERHUB_USERNAME" - password: "$DOCKERHUB_TOKEN" + docker-username: "$DOCKERHUB_USERNAME" + docker-password: "$DOCKERHUB_TOKEN" - docker/build-publish: image: "freikin/dawarich:<< parameters.tag >>" dockerfile: ./docker/Dockerfile.dev From f9cff143dd8bab1ce205525ce5ca6eed07204950 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:44:43 +0100 Subject: [PATCH 093/157] Update circle ci config --- .circleci/config.yml | 50 ++++++++++++++------------------------------ 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4a46e64d..c0b865ea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,10 +1,5 @@ version: 2.1 -parameters: - tag: - type: string - default: "rc" - executors: docker-executor: machine: @@ -46,37 +41,24 @@ jobs: - store_artifacts: path: coverage - build-and-push: - executor: - name: docker/docker - parameters: - tag: - type: string - default: "rc" - steps: - - checkout - - docker/login: - docker-username: "$DOCKERHUB_USERNAME" - docker-password: "$DOCKERHUB_TOKEN" - - docker/build-publish: - image: "freikin/dawarich:<< parameters.tag >>" - dockerfile: ./docker/Dockerfile.dev - build-args: "" - extra-build-args: "--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6" - registry: "docker.io" - push: true + workflows: version: 2 - build-and-test: + test: jobs: - test - - build-and-push: - requires: - - test - filters: - branches: - ignore: /.*/ # Run only on tag builds - tags: - only: /.*/ # Run for any tag - tag: "<< pipeline.parameters.tag >>" + build-docker-image-only: + jobs: + - docker/publish: + image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME + update-description: true + build-docker-image-only-with-buildkit: + jobs: + - docker/publish: + image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME + remote-docker-version: 20.10.12 + update-description: true + use-buildkit: true + use-remote-docker: true + From 66f3c0ae909c715804ada241e04e1c9bd1cc7ec0 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:46:35 +0100 Subject: [PATCH 094/157] Update circle ci config --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c0b865ea..1b74edf7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -57,8 +57,11 @@ workflows: jobs: - docker/publish: image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME + dockerfile: ./docker/Dockerfile.dev remote-docker-version: 20.10.12 update-description: true use-buildkit: true use-remote-docker: true + use-docker-credentials-store: true + tag: "rc" From 33b7dd09b922be8cb52fab5d4fd97b9d1576179c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:47:19 +0100 Subject: [PATCH 095/157] Update circle ci config --- .circleci/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1b74edf7..392d441d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,4 +64,6 @@ workflows: use-remote-docker: true use-docker-credentials-store: true tag: "rc" + docker-username: "$DOCKERHUB_USERNAME" + docker-password: "$DOCKERHUB_TOKEN" From 15e2fde2f25466c77d50db54e7961ab43325e178 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Feb 2025 22:48:30 +0100 Subject: [PATCH 096/157] Update circle ci config --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 392d441d..c59e1c13 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,6 +64,6 @@ workflows: use-remote-docker: true use-docker-credentials-store: true tag: "rc" - docker-username: "$DOCKERHUB_USERNAME" - docker-password: "$DOCKERHUB_TOKEN" + docker-username: DOCKERHUB_USERNAME + docker-password: DOCKERHUB_TOKEN From 3c81bd20e09628905c77f607279933d78ad37f4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:10:46 +0000 Subject: [PATCH 097/157] Bump ffaker from 2.23.0 to 2.24.0 Bumps [ffaker](https://github.com/ffaker/ffaker) from 2.23.0 to 2.24.0. - [Release notes](https://github.com/ffaker/ffaker/releases) - [Changelog](https://github.com/ffaker/ffaker/blob/main/Changelog.md) - [Commits](https://github.com/ffaker/ffaker/compare/v2.23.0...v2.24.0) --- updated-dependencies: - dependency-name: ffaker dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0bfd8209..a92bbe8d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -139,7 +139,7 @@ GEM factory_bot (~> 6.5) railties (>= 5.0.0) fakeredis (0.1.4) - ffaker (2.23.0) + ffaker (2.24.0) foreman (0.88.1) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) From d3ffe4670afccb2c19bc94ea78f945cddee9f5c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:11:21 +0000 Subject: [PATCH 098/157] Bump strong_migrations from 2.1.0 to 2.2.0 Bumps [strong_migrations](https://github.com/ankane/strong_migrations) from 2.1.0 to 2.2.0. - [Changelog](https://github.com/ankane/strong_migrations/blob/master/CHANGELOG.md) - [Commits](https://github.com/ankane/strong_migrations/compare/v2.1.0...v2.2.0) --- updated-dependencies: - dependency-name: strong_migrations dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0bfd8209..1427ae40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -399,8 +399,8 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) - strong_migrations (2.1.0) - activerecord (>= 6.1) + strong_migrations (2.2.0) + activerecord (>= 7) super_diff (0.15.0) attr_extras (>= 6.2.4) diff-lcs From 1a67878aaf2ffa977bf5cb93e289157f21e0d53c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 3 Feb 2025 20:00:50 +0100 Subject: [PATCH 099/157] Return to simple CircleCI config --- .circleci/config.yml | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c59e1c13..460be1ea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,14 +1,8 @@ version: 2.1 -executors: - docker-executor: - machine: - image: ubuntu-2204:current - orbs: ruby: circleci/ruby@2.1.4 browser-tools: circleci/browser-tools@1.4.8 - docker: circleci/docker@2.8.2 jobs: test: @@ -22,6 +16,7 @@ jobs: POSTGRES_DB: test_database POSTGRES_PASSWORD: mysecretpassword - image: redis:7.0 + steps: - checkout - run: @@ -41,29 +36,7 @@ jobs: - store_artifacts: path: coverage - - workflows: - version: 2 - test: + rspec: jobs: - test - build-docker-image-only: - jobs: - - docker/publish: - image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME - update-description: true - build-docker-image-only-with-buildkit: - jobs: - - docker/publish: - image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME - dockerfile: ./docker/Dockerfile.dev - remote-docker-version: 20.10.12 - update-description: true - use-buildkit: true - use-remote-docker: true - use-docker-credentials-store: true - tag: "rc" - docker-username: DOCKERHUB_USERNAME - docker-password: DOCKERHUB_TOKEN - From 1df5d514e15e49f374c80c627c46b5b5ed4bc8f1 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 18:56:06 +0100 Subject: [PATCH 100/157] Update Ruby to 3.4.1 and replace alpine docker image with bookworm --- .circleci/docker.yml | 34 ++++++++++++++++++++++++++++++++++ .circleci/local_config.yml | 32 ++++++++++++++++++++++++++++++++ .ruby-version | 2 +- Gemfile | 1 + Gemfile.lock | 15 ++++++++------- docker/Dockerfile.dev | 18 ++++++++++-------- 6 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 .circleci/docker.yml create mode 100644 .circleci/local_config.yml diff --git a/.circleci/docker.yml b/.circleci/docker.yml new file mode 100644 index 00000000..5d9a5804 --- /dev/null +++ b/.circleci/docker.yml @@ -0,0 +1,34 @@ +version: 2.1 + +jobs: + build-and-push: + docker: + - image: cimg/base:stable + steps: + - checkout + - setup_remote_docker + - run: + name: Login to DockerHub + command: | + echo "Attempting to login to DockerHub..." + echo "$DOCKERHUB_TOKEN" | sudo docker login -u "$DOCKERHUB_USERNAME" --password-stdin + - run: + name: Build and push Docker images + command: | + # Get the short SHA or use 'latest' as fallback + SHORT_SHA=${CIRCLE_SHA1:-rc1} + + sudo docker buildx create --use + sudo docker buildx build \ + --platform linux/amd64 \ + -t freikin/dawarich:${SHORT_SHA} \ + -t freikin/dawarich:rc \ + -f docker/Dockerfile.dev \ + --push . + +workflows: + version: 2 + build-and-push: + jobs: + - build-and-push: + context: dockerhub diff --git a/.circleci/local_config.yml b/.circleci/local_config.yml new file mode 100644 index 00000000..e9c7f207 --- /dev/null +++ b/.circleci/local_config.yml @@ -0,0 +1,32 @@ +version: 2 +jobs: + build-and-push: + docker: + - image: cimg/base:stable + steps: + - checkout + - setup_remote_docker + - run: + name: Login to DockerHub + command: | + echo "Attempting to login to DockerHub..." + echo "$DOCKERHUB_TOKEN" | sudo docker login -u "$DOCKERHUB_USERNAME" --password-stdin + - run: + name: Build and push Docker images + command: | + # Get the short SHA or use 'latest' as fallback + SHORT_SHA=${CIRCLE_SHA1:-rc1} + + sudo docker buildx create --use + sudo docker buildx build \ + --platform linux/amd64 \ + -t freikin/dawarich:${SHORT_SHA} \ + -t freikin/dawarich:rc \ + -f docker/Dockerfile.dev \ + --push . +workflows: + version: 2 + build-and-push: + jobs: + - build-and-push: + context: dockerhub diff --git a/.ruby-version b/.ruby-version index a0891f56..47b322c9 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.4 +3.4.1 diff --git a/Gemfile b/Gemfile index 0a30ff39..f4505e9b 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,7 @@ gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' gem 'racc', '~> 1.8', '>= 1.8.1' # Nokogiri dependency +gem 'nokogiri', '1.18.1' gem 'rgeo' gem 'rswag-api' gem 'rswag-ui' diff --git a/Gemfile.lock b/Gemfile.lock index 144f3b3d..6778fe11 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -223,18 +223,18 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.2) + nokogiri (1.18.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.2-aarch64-linux-gnu) + nokogiri (1.18.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.2-arm-linux-gnu) + nokogiri (1.18.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.2-arm64-darwin) + nokogiri (1.18.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.2-x86_64-darwin) + nokogiri (1.18.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.2-x86_64-linux-gnu) + nokogiri (1.18.1-x86_64-linux-gnu) racc (~> 1.4) oj (3.16.9) bigdecimal (>= 3.0) @@ -475,6 +475,7 @@ DEPENDENCIES importmap-rails kaminari lograge + nokogiri (= 1.18.1) oj pg prometheus_exporter @@ -507,7 +508,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.3.4p94 + ruby 3.4.1p0 BUNDLED WITH 2.5.21 diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 37b04015..710ad7d2 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM ruby:3.3.4-alpine +FROM ruby:3.4.1-bookworm ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 @@ -8,20 +8,22 @@ ENV RAILS_PORT=3000 ENV RAILS_ENV=development # Install dependencies for application -RUN apk -U add --no-cache \ - build-base \ +RUN apt-get update && apt-get install -y \ + build-essential \ git \ - postgresql-dev \ postgresql-client \ + libpq-dev \ libxml2-dev \ libxslt-dev \ nodejs \ - yarn \ + npm \ imagemagick \ tzdata \ less \ - yaml-dev \ - gcompat \ + libyaml-dev \ + && npm install -g yarn \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ && mkdir -p $APP_PATH # Update gem system and install bundler @@ -36,7 +38,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ # Install all gems into the image RUN bundle config set --local path 'vendor/bundle' \ && bundle install --jobs 4 --retry 3 \ - && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem + && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem # Copy the rest of the application COPY ../. ./ From b3cf8e50fcedc015c1b1d7c302759e5f1062e649 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 19:16:05 +0100 Subject: [PATCH 101/157] Add gcc, g++, make --- docker/Dockerfile.dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 710ad7d2..d07f1754 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -21,6 +21,9 @@ RUN apt-get update && apt-get install -y \ tzdata \ less \ libyaml-dev \ + gcc \ + g++ \ + make \ && npm install -g yarn \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ From d2166322210e74c110a1c745496e7e66d4601ab5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 20:03:52 +0100 Subject: [PATCH 102/157] Remove unused files and return arm64 support --- .circleci/docker.yml | 34 ---------------------------- .circleci/local_config.yml | 32 -------------------------- .github/workflows/build_and_push.yml | 2 +- 3 files changed, 1 insertion(+), 67 deletions(-) delete mode 100644 .circleci/docker.yml delete mode 100644 .circleci/local_config.yml diff --git a/.circleci/docker.yml b/.circleci/docker.yml deleted file mode 100644 index 5d9a5804..00000000 --- a/.circleci/docker.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: 2.1 - -jobs: - build-and-push: - docker: - - image: cimg/base:stable - steps: - - checkout - - setup_remote_docker - - run: - name: Login to DockerHub - command: | - echo "Attempting to login to DockerHub..." - echo "$DOCKERHUB_TOKEN" | sudo docker login -u "$DOCKERHUB_USERNAME" --password-stdin - - run: - name: Build and push Docker images - command: | - # Get the short SHA or use 'latest' as fallback - SHORT_SHA=${CIRCLE_SHA1:-rc1} - - sudo docker buildx create --use - sudo docker buildx build \ - --platform linux/amd64 \ - -t freikin/dawarich:${SHORT_SHA} \ - -t freikin/dawarich:rc \ - -f docker/Dockerfile.dev \ - --push . - -workflows: - version: 2 - build-and-push: - jobs: - - build-and-push: - context: dockerhub diff --git a/.circleci/local_config.yml b/.circleci/local_config.yml deleted file mode 100644 index e9c7f207..00000000 --- a/.circleci/local_config.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: 2 -jobs: - build-and-push: - docker: - - image: cimg/base:stable - steps: - - checkout - - setup_remote_docker - - run: - name: Login to DockerHub - command: | - echo "Attempting to login to DockerHub..." - echo "$DOCKERHUB_TOKEN" | sudo docker login -u "$DOCKERHUB_USERNAME" --password-stdin - - run: - name: Build and push Docker images - command: | - # Get the short SHA or use 'latest' as fallback - SHORT_SHA=${CIRCLE_SHA1:-rc1} - - sudo docker buildx create --use - sudo docker buildx build \ - --platform linux/amd64 \ - -t freikin/dawarich:${SHORT_SHA} \ - -t freikin/dawarich:rc \ - -f docker/Dockerfile.dev \ - --push . -workflows: - version: 2 - build-and-push: - jobs: - - build-and-push: - context: dockerhub diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 7269311a..cec9369f 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -77,6 +77,6 @@ jobs: file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/amd64,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm/v7,linux/arm/v6,linux/arm64 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From b30cdee89a0dd361d8239fb6c760572574b18045 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 20:25:34 +0100 Subject: [PATCH 103/157] Address rgeo install issues --- docker/Dockerfile.dev | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index d07f1754..fafa864d 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -24,6 +24,8 @@ RUN apt-get update && apt-get install -y \ gcc \ g++ \ make \ + libgeos-dev \ + libproj-dev \ && npm install -g yarn \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ From e6faa8e4b83113ca9a7e39961c400ae958b97738 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 20:47:12 +0100 Subject: [PATCH 104/157] Unify build and push actions --- .github/workflows/build_and_push.yml | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index cec9369f..bee32d42 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -19,10 +19,10 @@ jobs: with: ref: ${{ github.event.inputs.branch || github.ref_name }} - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 - name: Cache Docker layers uses: actions/cache@v4 @@ -59,24 +59,13 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - - name: Build and push (arm64) + - name: Build and push uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/arm64 + platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-arm64 - - - name: Build and push (other architectures) - uses: docker/build-push-action@v2 - with: - context: . - file: ./docker/Dockerfile.dev - push: true - tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/amd64,linux/arm/v7,linux/arm/v6,linux/arm64 - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new From 21022d56a6e0365e6e146e81caf3570bf268e3af Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 21:23:46 +0100 Subject: [PATCH 105/157] Update Dockerfile for ARM64 --- docker/Dockerfile.dev | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index fafa864d..fde81d23 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -21,15 +21,17 @@ RUN apt-get update && apt-get install -y \ tzdata \ less \ libyaml-dev \ - gcc \ - g++ \ + gcc-11 \ + g++-11 \ make \ libgeos-dev \ libproj-dev \ && npm install -g yarn \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p $APP_PATH + && mkdir -p $APP_PATH \ + && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 \ + && update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-11 100 # Update gem system and install bundler RUN gem update --system 3.6.2 \ @@ -40,9 +42,11 @@ WORKDIR $APP_PATH COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ -# Install all gems into the image +# Install all gems into the image with reduced parallelism for ARM64 RUN bundle config set --local path 'vendor/bundle' \ - && bundle install --jobs 4 --retry 3 \ + && bundle config set --local jobs 2 \ + && bundle config set build.nokogiri --use-system-libraries \ + && bundle install --retry 3 \ && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem # Copy the rest of the application From 13be27d311356fe2e7002f0a150b3142481998e4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 21:57:34 +0100 Subject: [PATCH 106/157] What do we have to lose --- docker/Dockerfile.dev | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index fde81d23..033ab493 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -21,8 +21,8 @@ RUN apt-get update && apt-get install -y \ tzdata \ less \ libyaml-dev \ - gcc-11 \ - g++-11 \ + gcc-10 \ + g++-10 \ make \ libgeos-dev \ libproj-dev \ @@ -30,8 +30,8 @@ RUN apt-get update && apt-get install -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p $APP_PATH \ - && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 \ - && update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-11 100 + && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 \ + && update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-10 100 # Update gem system and install bundler RUN gem update --system 3.6.2 \ @@ -42,10 +42,17 @@ WORKDIR $APP_PATH COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ +# Set environment variables for compilation +ENV CFLAGS="-O2 -pipe -fstack-protector-strong" \ + CXXFLAGS="-O2 -pipe -fstack-protector-strong" \ + MAKEFLAGS="-j2" + # Install all gems into the image with reduced parallelism for ARM64 RUN bundle config set --local path 'vendor/bundle' \ && bundle config set --local jobs 2 \ && bundle config set build.nokogiri --use-system-libraries \ + && bundle config build.racc --with-cflags="-O2 -pipe" \ + && bundle config build.oj --with-cflags="-O2 -pipe" \ && bundle install --retry 3 \ && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem From 927ca31471e110302e205876aff87d8b0b9484da Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 22:05:14 +0100 Subject: [PATCH 107/157] Update Dockerfile for ARM64 --- docker/Dockerfile.dev | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 033ab493..813f0beb 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -21,17 +21,15 @@ RUN apt-get update && apt-get install -y \ tzdata \ less \ libyaml-dev \ - gcc-10 \ - g++-10 \ + gcc \ + g++ \ make \ libgeos-dev \ libproj-dev \ && npm install -g yarn \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p $APP_PATH \ - && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 \ - && update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-10 100 + && mkdir -p $APP_PATH # Update gem system and install bundler RUN gem update --system 3.6.2 \ @@ -43,16 +41,16 @@ WORKDIR $APP_PATH COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ # Set environment variables for compilation -ENV CFLAGS="-O2 -pipe -fstack-protector-strong" \ - CXXFLAGS="-O2 -pipe -fstack-protector-strong" \ +ENV CFLAGS="-O2 -pipe -fstack-protector-strong -fno-strict-aliasing" \ + CXXFLAGS="-O2 -pipe -fstack-protector-strong -fno-strict-aliasing" \ MAKEFLAGS="-j2" # Install all gems into the image with reduced parallelism for ARM64 RUN bundle config set --local path 'vendor/bundle' \ && bundle config set --local jobs 2 \ && bundle config set build.nokogiri --use-system-libraries \ - && bundle config build.racc --with-cflags="-O2 -pipe" \ - && bundle config build.oj --with-cflags="-O2 -pipe" \ + && bundle config build.racc --with-cflags="-O2 -pipe -fno-strict-aliasing" \ + && bundle config build.oj --with-cflags="-O2 -pipe -fno-strict-aliasing" \ && bundle install --retry 3 \ && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem From 4662e97d067c244569960414010a3058b8658ae2 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 22:40:42 +0100 Subject: [PATCH 108/157] Remove gem specific compilation flags --- docker/Dockerfile.dev | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 813f0beb..6b9f01f2 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -43,14 +43,13 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ # Set environment variables for compilation ENV CFLAGS="-O2 -pipe -fstack-protector-strong -fno-strict-aliasing" \ CXXFLAGS="-O2 -pipe -fstack-protector-strong -fno-strict-aliasing" \ - MAKEFLAGS="-j2" + MAKEFLAGS="-j2" \ + BUNDLE_FORCE_RUBY_PLATFORM="true" # Install all gems into the image with reduced parallelism for ARM64 RUN bundle config set --local path 'vendor/bundle' \ && bundle config set --local jobs 2 \ && bundle config set build.nokogiri --use-system-libraries \ - && bundle config build.racc --with-cflags="-O2 -pipe -fno-strict-aliasing" \ - && bundle config build.oj --with-cflags="-O2 -pipe -fno-strict-aliasing" \ && bundle install --retry 3 \ && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem From 7579ad75f36ded90ea18e5623475dd13ba995b81 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 5 Feb 2025 23:19:50 +0100 Subject: [PATCH 109/157] Update Dockerfile.dev for ARM64 --- docker/Dockerfile.dev | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 6b9f01f2..fed795ea 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -8,11 +8,13 @@ ENV RAILS_PORT=3000 ENV RAILS_ENV=development # Install dependencies for application -RUN apt-get update && apt-get install -y \ +RUN dpkg --add-architecture arm64 && \ + apt-get update && apt-get install -y \ build-essential \ git \ - postgresql-client \ - libpq-dev \ + postgresql-client:arm64 \ + libpq-dev:arm64 \ + libpq5:arm64 \ libxml2-dev \ libxslt-dev \ nodejs \ From ebd4694e9fa87c35f4a96b1038df44175fecd215 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 18:01:22 +0100 Subject: [PATCH 110/157] Change to ubuntu-22.04 for github actions --- .github/workflows/build_and_push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index bee32d42..a306871a 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -12,7 +12,7 @@ on: jobs: build-and-push-docker: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 From 9837c093fe9076429c40165980586693ad17112f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 18:06:03 +0100 Subject: [PATCH 111/157] Update github actions to use ubuntu-22.04 and use old actions file --- .github/workflows/build_and_push.yml | 7 ++--- docker/Dockerfile.dev | 39 ++++++++-------------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index a306871a..49580227 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -15,9 +15,10 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch || github.ref_name }} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -60,7 +61,7 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v5 with: context: . file: ./docker/Dockerfile.dev @@ -68,4 +69,4 @@ jobs: tags: ${{ steps.docker_meta.outputs.tags }} platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new + cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index fed795ea..41b65721 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM ruby:3.4.1-bookworm +FROM ruby:3.4.1-alpine ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 @@ -8,29 +8,20 @@ ENV RAILS_PORT=3000 ENV RAILS_ENV=development # Install dependencies for application -RUN dpkg --add-architecture arm64 && \ - apt-get update && apt-get install -y \ - build-essential \ +RUN apk -U add --no-cache \ + build-base \ git \ - postgresql-client:arm64 \ - libpq-dev:arm64 \ - libpq5:arm64 \ + postgresql-dev \ + postgresql-client \ libxml2-dev \ libxslt-dev \ nodejs \ - npm \ + yarn \ imagemagick \ tzdata \ less \ - libyaml-dev \ - gcc \ - g++ \ - make \ - libgeos-dev \ - libproj-dev \ - && npm install -g yarn \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* \ + yaml-dev \ + gcompat \ && mkdir -p $APP_PATH # Update gem system and install bundler @@ -42,18 +33,10 @@ WORKDIR $APP_PATH COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ -# Set environment variables for compilation -ENV CFLAGS="-O2 -pipe -fstack-protector-strong -fno-strict-aliasing" \ - CXXFLAGS="-O2 -pipe -fstack-protector-strong -fno-strict-aliasing" \ - MAKEFLAGS="-j2" \ - BUNDLE_FORCE_RUBY_PLATFORM="true" - -# Install all gems into the image with reduced parallelism for ARM64 +# Install all gems into the image RUN bundle config set --local path 'vendor/bundle' \ - && bundle config set --local jobs 2 \ - && bundle config set build.nokogiri --use-system-libraries \ - && bundle install --retry 3 \ - && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem + && bundle install --jobs 4 --retry 3 \ + && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem # Copy the rest of the application COPY ../. ./ From 6072e46affcac0442772ee25197a6cd0b8a57600 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 19:38:14 +0100 Subject: [PATCH 112/157] Update Ruby version in CircleCI --- .circleci/config.yml | 2 +- CHANGELOG.md | 3 ++- Gemfile | 2 -- Gemfile.lock | 2 -- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 460be1ea..ff43fbcc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ orbs: jobs: test: docker: - - image: cimg/ruby:3.3.4 + - image: cimg/ruby:3.4.1 environment: RAILS_ENV: test - image: cimg/postgres:13.3-postgis diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd4398c..9024ee26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,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.23.6 - 2025-01-29 +# 0.23.6 - 2025-02-06 ### Added @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed +- Ruby version was updated to 3.4.1. - Requesting photos on the Map page now uses the start and end dates from the URL params. #589 # 0.23.5 - 2025-01-22 diff --git a/Gemfile b/Gemfile index f4505e9b..592c2fd3 100644 --- a/Gemfile +++ b/Gemfile @@ -23,8 +23,6 @@ gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapt gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' -gem 'racc', '~> 1.8', '>= 1.8.1' # Nokogiri dependency -gem 'nokogiri', '1.18.1' gem 'rgeo' gem 'rswag-api' gem 'rswag-ui' diff --git a/Gemfile.lock b/Gemfile.lock index 6778fe11..4bf8fa71 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -475,7 +475,6 @@ DEPENDENCIES importmap-rails kaminari lograge - nokogiri (= 1.18.1) oj pg prometheus_exporter @@ -483,7 +482,6 @@ DEPENDENCIES pry-rails puma pundit - racc (~> 1.8, >= 1.8.1) rails (~> 8.0) redis rgeo From 29529b60f64bea94bc8193ff409d5f0ff66b35a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Feb 2025 18:45:11 +0000 Subject: [PATCH 113/157] Bump chartkick from 5.1.2 to 5.1.3 Bumps [chartkick](https://github.com/ankane/chartkick) from 5.1.2 to 5.1.3. - [Changelog](https://github.com/ankane/chartkick/blob/master/CHANGELOG.md) - [Commits](https://github.com/ankane/chartkick/compare/v5.1.2...v5.1.3) --- updated-dependencies: - dependency-name: chartkick dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a9afa268..e45b4984 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,7 +102,7 @@ GEM msgpack (~> 1.2) builder (3.3.0) byebug (11.1.3) - chartkick (5.1.2) + chartkick (5.1.3) coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.0) From 4e217b8a114499ee80d386cc33d2b3f93a438e27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Feb 2025 18:45:16 +0000 Subject: [PATCH 114/157] Bump puma from 6.5.0 to 6.6.0 Bumps [puma](https://github.com/puma/puma) from 6.5.0 to 6.6.0. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/master/History.md) - [Commits](https://github.com/puma/puma/compare/v6.5.0...v6.6.0) --- updated-dependencies: - dependency-name: puma dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a9afa268..25fffd6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -267,7 +267,7 @@ GEM date stringio public_suffix (6.0.1) - puma (6.5.0) + puma (6.6.0) nio4r (~> 2.0) pundit (2.4.0) activesupport (>= 3.0.0) From bd39b295a438d259b6c182f6d3198d45d4ef3307 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 19:50:03 +0100 Subject: [PATCH 115/157] Return app version in health response --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ app/controllers/api/v1/health_controller.rb | 2 ++ spec/requests/api/v1/health_spec.rb | 6 ++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.app_version b/.app_version index df47809d..379191a4 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.6 +0.23.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9024ee26..095bcdb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.7 - 2025-02-06 + +### Added + +- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. + # 0.23.6 - 2025-02-06 ### Added diff --git a/app/controllers/api/v1/health_controller.rb b/app/controllers/api/v1/health_controller.rb index 87df7d96..8e13d165 100644 --- a/app/controllers/api/v1/health_controller.rb +++ b/app/controllers/api/v1/health_controller.rb @@ -10,6 +10,8 @@ class Api::V1::HealthController < ApiController response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!') end + response.set_header('X-Dawarich-Version', APP_VERSION) + render json: { status: 'ok' } end end diff --git a/spec/requests/api/v1/health_spec.rb b/spec/requests/api/v1/health_spec.rb index 4861b399..139a207c 100644 --- a/spec/requests/api/v1/health_spec.rb +++ b/spec/requests/api/v1/health_spec.rb @@ -23,5 +23,11 @@ RSpec.describe 'Api::V1::Healths', type: :request do expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive and authenticated!') end end + + it 'returns the correct version' do + get '/api/v1/health' + + expect(response.headers['X-Dawarich-Version']).to eq(APP_VERSION) + end end end From 0a27dfefbe94761fde5073bfa5773955cb508a96 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 20:08:14 +0100 Subject: [PATCH 116/157] Fix deleting points --- .app_version | 2 +- CHANGELOG.md | 6 ++++++ app/assets/builds/tailwind.css | 2 +- app/javascript/controllers/maps_controller.js | 19 ++++++++++--------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.app_version b/.app_version index df47809d..379191a4 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.6 +0.23.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9024ee26..cfb563e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.23.7 - 2025-02-06 + +### Fixed + +- After deleting one point from the map, other points can now be deleted as well. #723 + # 0.23.6 - 2025-02-06 ### Added diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index a062f2a9..e3aa1942 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -2,5 +2,5 @@ --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact .timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 997821da..10927ac3 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -454,6 +454,9 @@ export default class extends Controller { return response.json(); }) .then(data => { + // Show success message + showFlashMessage('notice', 'Point was successfully deleted'); + // Remove the marker and update all layers this.removeMarker(id); let wasPolyLayerVisible = false; @@ -463,7 +466,6 @@ export default class extends Controller { wasPolyLayerVisible = true; } this.map.removeLayer(this.polylinesLayer); - } // Create new polylines layer @@ -485,17 +487,16 @@ export default class extends Controller { if (this.layerControl) { this.map.removeControl(this.layerControl); const controlsLayer = { - Points: this.markersLayer, - Routes: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer, - Areas: this.areasLayer, - Photos: this.photoMarkers + Points: this.markersLayer || L.layerGroup(), + Routes: this.polylinesLayer || L.layerGroup(), + Heatmap: this.heatmapLayer || L.heatLayer([]), + "Fog of War": new this.fogOverlay(), + "Scratch map": this.scratchLayer || L.layerGroup(), + Areas: this.areasLayer || L.layerGroup(), + Photos: this.photoMarkers || L.layerGroup() }; this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); } - // Update heatmap this.heatmapLayer.setLatLngs(this.markers.map(marker => [marker[0], marker[1], 0.2])); From f32dd6c9d7497e1fc3b603e333745a3a180ec8c1 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 27 Jan 2025 20:37:23 +0100 Subject: [PATCH 117/157] fix: set dbname in psql entrypoint commands --- docker/sidekiq-entrypoint.sh | 2 +- docker/web-entrypoint.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/sidekiq-entrypoint.sh b/docker/sidekiq-entrypoint.sh index 1083891b..3b851c4d 100644 --- a/docker/sidekiq-entrypoint.sh +++ b/docker/sidekiq-entrypoint.sh @@ -24,7 +24,7 @@ fi # Wait for the database to become available echo "⏳ Waiting for database to be ready..." -until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -d "$DATABASE_NAME" -c '\q'; do >&2 echo "Postgres is unavailable - retrying..." sleep 2 done diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh index 230f91cc..5c82d1b0 100644 --- a/docker/web-entrypoint.sh +++ b/docker/web-entrypoint.sh @@ -29,14 +29,14 @@ rm -f $APP_PATH/tmp/pids/server.pid # Wait for the database to become available echo "⏳ Waiting for database to be ready..." -until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c '\q'; do >&2 echo "Postgres is unavailable - retrying..." sleep 2 done echo "✅ PostgreSQL is ready!" # Create database if it doesn't exist -if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then +if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then echo "Creating database $DATABASE_NAME..." bundle exec rails db:create fi From 078b278c9faa53f24cd8a82499bbfe69233bb385 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 27 Jan 2025 20:41:40 +0100 Subject: [PATCH 118/157] fix copy paste mistake --- docker/sidekiq-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/sidekiq-entrypoint.sh b/docker/sidekiq-entrypoint.sh index 3b851c4d..cc4e20cd 100644 --- a/docker/sidekiq-entrypoint.sh +++ b/docker/sidekiq-entrypoint.sh @@ -24,7 +24,7 @@ fi # Wait for the database to become available echo "⏳ Waiting for database to be ready..." -until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -d "$DATABASE_NAME" -c '\q'; do +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c '\q'; do >&2 echo "Postgres is unavailable - retrying..." sleep 2 done From 41da68a4c8153d412a3ed548dcc70f13ff53782c Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Thu, 6 Feb 2025 22:24:53 +0100 Subject: [PATCH 119/157] Revert "Fix deleting points" --- CHANGELOG.md | 6 +----- app/assets/builds/tailwind.css | 2 +- app/javascript/controllers/maps_controller.js | 19 +++++++++---------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bedf598..095bcdb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,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.23.7 - 2025-02-06 - -### Fixed - -- After deleting one point from the map, other points can now be deleted as well. #723 +## 0.23.7 - 2025-02-06 ### Added diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index e3aa1942..a062f2a9 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -2,5 +2,5 @@ --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact .timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 10927ac3..997821da 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -454,9 +454,6 @@ export default class extends Controller { return response.json(); }) .then(data => { - // Show success message - showFlashMessage('notice', 'Point was successfully deleted'); - // Remove the marker and update all layers this.removeMarker(id); let wasPolyLayerVisible = false; @@ -466,6 +463,7 @@ export default class extends Controller { wasPolyLayerVisible = true; } this.map.removeLayer(this.polylinesLayer); + } // Create new polylines layer @@ -487,16 +485,17 @@ export default class extends Controller { if (this.layerControl) { this.map.removeControl(this.layerControl); const controlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": new this.fogOverlay(), - "Scratch map": this.scratchLayer || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup() + Points: this.markersLayer, + Routes: this.polylinesLayer, + Heatmap: this.heatmapLayer, + "Fog of War": this.fogOverlay, + "Scratch map": this.scratchLayer, + Areas: this.areasLayer, + Photos: this.photoMarkers }; this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); } + // Update heatmap this.heatmapLayer.setLatLngs(this.markers.map(marker => [marker[0], marker[1], 0.2])); From 344d0c7ec152ffc6e9c74158b4ce092cd666a2d5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 22:27:58 +0100 Subject: [PATCH 120/157] Update changelog and docker compose --- CHANGELOG.md | 4 ++-- docker/docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bedf598..1256b1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- After deleting one point from the map, other points can now be deleted as well. #723 +- After deleting one point from the map, other points can now be deleted as well. #723 #678 ### Added -- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. +- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. #800 # 0.23.6 - 2025-02-06 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 93c0296f..ee80f3fc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,7 +17,7 @@ services: start_period: 30s timeout: 10s dawarich_db: - image: postgres:14.2-alpine + image: postgis/postgis:14-3.5-alpine shm_size: 1G container_name: dawarich_db volumes: From 085ceb16e28bf07b4b7a6c36aaa2c29fc842c602 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 6 Feb 2025 22:52:19 +0100 Subject: [PATCH 121/157] Fix export file deletion --- CHANGELOG.md | 2 +- app/controllers/exports_controller.rb | 6 +++++- spec/requests/exports_spec.rb | 29 +++++++++++++++++---------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1256b1f9..dbef78f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - After deleting one point from the map, other points can now be deleted as well. #723 #678 - +- Fixed a bug where export file was not being deleted from the server after it was deleted. #808 ### Added - `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. #800 diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb index 6f9b4c65..34b239dc 100644 --- a/app/controllers/exports_controller.rb +++ b/app/controllers/exports_controller.rb @@ -23,7 +23,11 @@ class ExportsController < ApplicationController end def destroy - @export.destroy + ActiveRecord::Base.transaction do + @export.destroy + + File.delete(Rails.root.join('public', 'exports', @export.name)) + end redirect_to exports_url, notice: 'Export was successfully destroyed.', status: :see_other end diff --git a/spec/requests/exports_spec.rb b/spec/requests/exports_spec.rb index 0ec6fa61..2c5a6b72 100644 --- a/spec/requests/exports_spec.rb +++ b/spec/requests/exports_spec.rb @@ -76,9 +76,25 @@ RSpec.describe '/exports', type: :request do end describe 'DELETE /destroy' do - let!(:export) { create(:export, user:, url: 'exports/export.json') } + let!(:export) { create(:export, user:, url: 'exports/export.json', name: 'export.json') } + let(:export_file) { Rails.root.join('public', 'exports', export.name) } - before { sign_in user } + before do + sign_in user + + FileUtils.mkdir_p(File.dirname(export_file)) + File.write(export_file, '{"some": "data"}') + end + + after { FileUtils.rm_f(export_file) } + + it 'removes the export file from disk' do + expect(File.exist?(export_file)).to be true + + delete export_url(export) + + expect(File.exist?(export_file)).to be false + end it 'destroys the requested export' do expect { delete export_url(export) }.to change(Export, :count).by(-1) @@ -89,14 +105,5 @@ RSpec.describe '/exports', type: :request do expect(response).to redirect_to(exports_url) end - - it 'remove the export file from the disk' do - export_file = Rails.root.join('public', export.url) - FileUtils.touch(export_file) - - delete export_url(export) - - expect(File.exist?(export_file)).to be_falsey - end end end From fea87b85bb8ba518933457f12d790f0955cc415d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 7 Feb 2025 19:17:28 +0100 Subject: [PATCH 122/157] Add docs and few fixes --- CHANGELOG.md | 2087 +++++++++++++++++ app/assets/stylesheets/application.css | 5 + app/javascript/controllers/maps_controller.js | 48 +- app/javascript/maps/areas.js | 112 +- .../google_maps/phone_takeout_parser.rb | 4 +- app/services/own_tracks/params.rb | 14 +- spec/fixtures/files/owntracks/2024-03.rec | 4 +- .../services/own_tracks/export_parser_spec.rb | 10 +- spec/services/own_tracks/params_spec.rb | 4 +- spec/swagger/api/v1/areas_controller_spec.rb | 54 +- .../api/v1/countries/visited_cities_spec.rb | 37 +- spec/swagger/api/v1/health_controller_spec.rb | 16 + .../v1/overland/batches_controller_spec.rb | 111 +- .../v1/owntracks/points_controller_spec.rb | 46 +- spec/swagger/api/v1/points_controller_spec.rb | 76 +- .../api/v1/settings_controller_spec.rb | 52 +- swagger/v1/swagger.yaml | 198 +- 17 files changed, 2729 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbef78f8..d2f2d5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,2093 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # 0.23.7 - 2025-02-06 +## Points speed units + +Dawarich expects speed to be sent in meters per second. It's already known that OwnTracks and GPSLogger (in some configurations) are sending speed in kilometers per hour. + +In GPSLogger it's easily fixable: if you previously had `"vel": "%SPD_KMH"`, change it to `"vel": "%SPD"`, like it's described in the [docs](https://dawarich.app/docs/tutorials/track-your-location#gps-logger). + +In OwnTracks it's a bit more complicated. You can't change the speed unit in the settings, so Dawarich will expect speed in kilometers per hour and will convert it to meters per second. Nothing is needed to be done from your side. + +Now, we need to fix existing points with speed in kilometers per hour. The following guide assumes that you have been tracking your location exclusively with speed in kilometers per hour. If you have been using both speed units (say, were tracking with OwnTracks in kilometers per hour and with GPSLogger in meters per second), you need to decide what to do with points that have speed in kilometers per hour, as there is no easy way to distinguish them from points with speed in meters per second. + +To convert speed in kilometers per hour to meters per second in your points, follow these steps: + +1. Enter [Dawarich console](https://dawarich.app/docs/FAQ#how-to-enter-dawarich-console) +2. Run `points = Point.where(import_id: nil).where("velocity != ? OR velocity != ?", nil, "0")`. This will return all tracked (not imported) points. +3. Run +```ruby +points.update_all("velocity = CAST(ROUND(CAST((CAST(velocity AS FLOAT) * 1000 / 3600) AS NUMERIC), 1) AS TEXT)") + +``` + +This will convert speed in kilometers per hour to meters per second and round it to 1 decimal place. + +If you have been using both speed units, but you know the dates where you were tracking with speed in kilometers per hour, on the second step of the instruction above, you can add `where("timestamp BETWEEN ? AND ?", "2025-01-01", "2025-01-31")` to the query to convert speed in kilometers per hour to meters per second only for a specific period of time. Resulting query will look like this: + +```ruby +points = Point.where(import_id: nil).where("velocity != ? OR velocity != ?", nil, "0").where("timestamp BETWEEN ? AND ?", "2025-01-01", "2025-01-31") +``` + +This will select points tracked between January 1st and January 31st 2025. Then just use step 3 to convert speed in kilometers per hour to meters per second. + +### Changed + +- Speed for points, that are sent to Dawarich via `POST /api/v1/owntracks/points` endpoint, will now be converted to meters per second, if `topic` param is sent. The official GPSLogger instructions are assuming user won't be sending `topic` param, so this shouldn't affect you if you're using GPSLogger. + +### Fixed + +- After deleting one point from the map, other points can now be deleted as well. #723 #678 +- Fixed a bug where export file was not being deleted from the server after it was deleted. #808 +- After an area was drawn on the map, a popup is now being shown to allow user to provide a name and save the area. #740 + +### Added + +- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. #800 + +# 0.23.6 - 2025-02-06 + +### Added + +- Enabled Postgis extension for PostgreSQL. +- Trips are now store their paths in the database independently of the points. +- Trips are now being rendered on the map using their precalculated paths instead of list of coordinates. + +### Changed + +- Ruby version was updated to 3.4.1. +- Requesting photos on the Map page now uses the start and end dates from the URL params. #589 + +# 0.23.5 - 2025-01-22 + +### Added + +- A test for building rc Docker image. + +### Fixed + +- Fix authentication to `GET /api/v1/countries/visited_cities` with header `Authorization: Bearer YOUR_API_KEY` instead of `api_key` query param. #679 +- Fix a bug where a gpx file with empty tracks was not being imported. #646 +- Fix a bug where rc version was being checked as a stable release. #711 + +# 0.23.3 - 2025-01-21 + +### Changed + +- Synology-related files are now up to date. #684 + +### Fixed + +- Drastically improved performance for Google's Records.json import. It will now take less than 5 minutes to import 500,000 points, which previously took a few hours. + +### Fixed + +- Add index only if it doesn't exist. + +# 0.23.1 - 2025-01-21 + +### Fixed + +- Renamed unique index on points to `unique_points_lat_long_timestamp_user_id_index` to fix naming conflict with `unique_points_index`. + +# 0.23.0 - 2025-01-20 + +## ⚠️ IMPORTANT ⚠️ + +This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a [backup](https://dawarich.app/docs/tutorials/backup-and-restore) before updating to this version. + +### Added + +- `POST /api/v1/points/create` endpoint added. +- An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations. +- `GET /api/v1/users/me` endpoint added to get current user. + +# 0.22.4 - 2025-01-20 + +### Added + +- You can now drag-n-drop a point on the map to update its position. Enable the "Points" layer on the map to see the points. +- `PATCH /api/v1/points/:id` endpoint added to update a point. It only accepts `latitude` and `longitude` params. #51 #503 + +### Changed + +- Run seeds even in prod env so Unraid users could have default user. +- Precompile assets in production env using dummy secret key base. + +### Fixed + +- Fixed a bug where route wasn't highlighted when it was hovered or clicked. + +# 0.22.3 - 2025-01-14 + +### Changed + +- The Map now uses a canvas to draw polylines, points and fog of war. This should improve performance in browser with a lot of points and polylines. + +# 0.22.2 - 2025-01-13 + +✨ The Fancy Routes release ✨ + +### Added + +- In the Map Settings (coggle in the top left corner of the map), you can now enable/disable the Fancy Routes feature. Simply said, it will color your routes based on the speed of each segment. +- Hovering over a polyline now shows the speed of the segment. Move cursor over a polyline to see the speed of different segments. +- Distance and points number in the custom control to the map. + +### Changed + +- The name of the "Polylines" feature is now "Routes". + +⚠️ Important note on the Prometheus monitoring ⚠️ + +In the previous release, `bin/dev` command in the default `docker-compose.yml` file was replaced with `bin/rails server -p 3000 -b ::`, but this way Dawarich won't be able to start Prometheus Exporter. If you want to use Prometheus monitoring, you need to use `bin/dev` command instead. + +Example: + +```diff + dawarich_app: + image: freikin/dawarich:latest +... +- command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] ++ command: ['bin/dev'] +``` + +# 0.22.1 - 2025-01-09 + +### Removed + +- Gems caching volume from the `docker-compose.yml` file. + +To update existing `docker-compose.yml` to new changes, refer to the following: + +```diff + dawarich_app: + image: freikin/dawarich:latest +... + volumes: +- - dawarich_gem_cache_app:/usr/local/bundle/gems +... + dawarich_sidekiq: + image: freikin/dawarich:latest +... + volumes: +- - dawarich_gem_cache_app:/usr/local/bundle/gems +... + +volumes: + dawarich_db_data: +- dawarich_gem_cache_app: +- dawarich_gem_cache_sidekiq: + dawarich_shared: + dawarich_public: + dawarich_watched: +``` + +### Changed + +- `GET /api/v1/health` endpoint now returns a `X-Dawarich-Response: Hey, Im alive and authenticated!` header if user is authenticated. + +# 0.22.0 - 2025-01-09 + +⚠️ This release introduces a breaking change. ⚠️ + +Please read this release notes carefully before upgrading. + +Docker-related files were moved to the `docker` directory and some of them were renamed. Before upgrading, study carefully changes in the `docker/docker-compose.yml` file and update your docker-compose file accordingly, so it uses the new files and commands. Copying `docker/docker-compose.yml` blindly may lead to errors. + +No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues. + +To update existing `docker-compose.yml` to new changes, refer to the following: + +```diff + dawarich_app: + image: freikin/dawarich:latest +... +- entrypoint: dev-entrypoint.sh +- command: ['bin/dev'] ++ entrypoint: web-entrypoint.sh ++ command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] +... + dawarich_sidekiq: + image: freikin/dawarich:latest +... +- entrypoint: dev-entrypoint.sh +- command: ['bin/dev'] ++ entrypoint: sidekiq-entrypoint.sh ++ command: ['bundle', 'exec', 'sidekiq'] +``` + +Although `docker-compose.production.yml` was added, it's not being used by default. It's just an example of how to configure Dawarich for production. The default `docker-compose.yml` file is still recommended for running the app. + +### Changed + +- All docker-related files were moved to the `docker` directory. +- Default memory limit for `dawarich_app` and `dawarich_sidekiq` services was increased to 4GB. +- `dawarich_app` and `dawarich_sidekiq` services now use separate entrypoint scripts. +- Gems (dependency libraries) are now being shipped as part of the Dawarich Docker image. + +### Fixed + +- Visit suggesting job does nothing if user has no tracked points. +- `BulkStatsCalculationJob` now being called without arguments in the data migration. + +### Added + +- A proper production Dockerfile, docker-compose and env files. + +# 0.21.6 - 2025-01-07 + +### Changed + +- Disabled visit suggesting job after import. +- Improved performance of the `User#years_tracked` method. + +### Fixed + +- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605 +- Points are now being rendered with higher z-index than polylines. #577 +- Run cache cleaning and preheating jobs only on server start. #594 + +# 0.21.5 - 2025-01-07 + +You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/). + +### Added + +- Geoapify API support for reverse geocoding. Provide `GEOAPIFY_API_KEY` env var to use it. + +### Removed + +- Photon ENV vars from the `.env.development` and docker-compose.yml files. +- `APPLICATION_HOST` env var. +- `REVERSE_GEOCODING_ENABLED` env var. + +# 0.21.4 - 2025-01-05 + +### Fixed + +- Fixed a bug where Photon API for patreon supporters was not being used for reverse geocoding. + +# 0.21.3 - 2025-01-04 + +### Added + +- A notification about Photon API being under heavy load. + +### Removed + +- The notification about telemetry being enabled. + +### Reverted + +- ~~Imported points will now be reverse geocoded only after import is finished.~~ + +# 0.21.2 - 2024-12-25 + +### Added + +- Logging for Immich responses. +- Watcher now supports all data formats that can be imported via web interface. + +### Changed + +- Imported points will now be reverse geocoded only after import is finished. + +### Fixed + +- Markers on the map are now being rendered with higher z-index than polylines. #577 + +# 0.21.1 - 2024-12-24 + +### Added + +- Cache cleaning and preheating upon application start. +- `PHOTON_API_KEY` env var to set Photon API key. It's an optional env var, but it's required if you want to use Photon API as a Patreon supporter. +- 'X-Dawarich-Response' header to the `GET /api/v1/health` endpoint. It's set to 'Hey, I\'m alive!' to make it easier to check if the API is working. + +### Changed + +- Custom config for PostgreSQL is now optional in `docker-compose.yml`. + +# 0.21.0 - 2024-12-20 + +⚠️ This release introduces a breaking change. ⚠️ + +The `dawarich_db` service now uses a custom `postgresql.conf` file. + +As @tabacha pointed out in #549, the default `shm_size` for the `dawarich_db` service is too small and it may lead to database performance issues. This release introduces a `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. Also, it introduces a custom `postgresql.conf` file to the `dawarich_db` service. + +To mount a custom `postgresql.conf` file, you need to create a `postgresql.conf` file in the `dawarich_db` service directory and add the following line to it: + +```diff + dawarich_db: + image: postgres:14.2-alpine + shm_size: 1G + container_name: dawarich_db + volumes: + - dawarich_db_data:/var/lib/postgresql/data + - dawarich_shared:/var/shared ++ - ./postgresql.conf:/etc/postgresql/postgres.conf # Provide path to custom config + ... + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s ++ command: postgres -c config_file=/etc/postgresql/postgres.conf # Use custom config +``` + +To ensure your database is using custom config, you can connect to the container (`docker exec -it dawarich_db psql -U postgres`) and run `SHOW config_file;` command. It should return the following path: `/etc/postgresql/postgresql.conf`. + +An example of a custom `postgresql.conf` file is provided in the `postgresql.conf.example` file. + +### Added + +- A button on a year stats card to update stats for the whole year. #466 +- A button on a month stats card to update stats for a specific month. #466 +- A confirmation alert on the Notifications page before deleting all notifications. +- A `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. + +```diff + ... + dawarich_db: + image: postgres:14.2-alpine ++ shm_size: 1G + ... +``` + +- In addition to `api_key` parameter, `Authorization` header is now being used to authenticate API requests. #543 + +Example: + +``` +Authorization: Bearer YOUR_API_KEY +``` + +### Changed + +- The map borders were expanded to make it easier to scroll around the map for New Zealanders. +- The `dawarich_db` service now uses a custom `postgresql.conf` file. +- The popup over polylines now shows dates in the user's format, based on their browser settings. + +# 0.20.2 - 2024-12-17 + +### Added + +- A point id is now being shown in the point popup. + +### Fixed + +- North Macedonia is now being shown on the scratch map. #537 + +### Changed + +- The app process is now bound to :: instead of 0.0.0.0 to provide compatibility with IPV6. +- The app was updated to use Rails 8.0.1. + +# 0.20.1 - 2024-12-16 + +### Fixed + +- Setting `reverse_geocoded_at` for points that don't have geodata is now being performed in background job, in batches of 10,000 points to prevent memory exhaustion and long-running data migration. + +# 0.20.0 - 2024-12-16 + +### Added + +- `GET /api/v1/points/tracked_months` endpoint added to get list of tracked years and months. +- `GET /api/v1/countries/visited_cities` endpoint added to get list of visited cities. +- A link to the docs leading to a help chart for k8s. #550 +- A button to delete all notifications. #548 +- A support for `RAILS_LOG_LEVEL` env var to change log level. More on that here: https://guides.rubyonrails.org/debugging_rails_applications.html#log-levels. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`, corresponding to the log level numbers from 0 up to 5, respectively. The default log level is `:debug`. #540 +- A devcontainer to improve developers experience. #546 + +### Fixed + +- A point popup is no longer closes when hovering over a polyline. #536 +- When polylines layer is disabled and user deletes a point from its popup, polylines layer is no longer being enabled right away. #552 +- Paths to gems within the sidekiq and app containers. #499 + +### Changed + +- Months and years navigation is moved to a map panel on the right side of the map. +- List of visited cities is now being shown in a map panel on the right side of the map. + +# 0.19.7 - 2024-12-11 + +### Fixed + +- Fixed a bug where upon deleting a point on the map, the confirmation dialog was shown multiple times and the point was not being deleted from the map until the page was reloaded. #435 + +### Changed + +- With the "Points" layer enabled on the map, points with negative speed are now being shown in orange color. Since Overland reports negative speed for points that might be faulty, this should help you to identify them. +- On the Points page, speed of the points with negative speed is now being shown in red color. + +# 0.19.6 - 2024-12-11 + +⚠️ This release introduces a breaking change. ⚠️ + +The `dawarich_shared` volume now being mounted to `/data` instead of `/var/shared` within the container. It fixes Redis data being lost on container restart. + +To change this, you need to update the `docker-compose.yml` file: + +```diff + dawarich_redis: + image: redis:7.0-alpine + container_name: dawarich_redis + command: redis-server + volumes: ++ - dawarich_shared:/data + restart: always + healthcheck: +``` + +Telemetry is now disabled by default. To enable it, you need to set `ENABLE_TELEMETRY` env var to `true`. For those who have telemetry enabled using `DISABLE_TELEMETRY` env var set to `false`, telemetry is now disabled by default. + +### Fixed + +- Flash messages are now being removed after 5 seconds. +- Fixed broken migration that was preventing the app from starting. +- Visits page is now loading a lot faster than before. +- Redis data should now be preserved on container restart. +- Fixed a bug where export files could have double extension, e.g. `file.gpx.gpx`. + +### Changed + +- Places page is now accessible from the Visits & Places tab on the navbar. +- Exporting process is now being logged. +- `ENABLE_TELEMETRY` env var is now used instead of `DISABLE_TELEMETRY` to enable/disable telemetry. + +# 0.19.5 - 2024-12-10 + +### Fixed + +- Fixed a bug where the map and visits pages were throwing an error due to incorrect approach to distance calculation. + +# 0.19.4 - 2024-12-10 + +⚠️ This release introduces a breaking change. ⚠️ + +The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of the response: + +```diff +{ + id: 1, + latitude: 10, + longitude: 10, + localDateTime: "2024-01-01T00:00:00Z", + originalFileName: "photo.jpg", + city: "Berlin", + state: "Berlin", + country: "Germany", + type: "image", ++ orientation: "portrait", + source: "photoprism" +} +``` + +### Fixed + +- Fixed a bug where the Photoprism photos were not being shown on the trip page. +- Fixed a bug where the Immich photos were not being shown on the trip page. +- Fixed a bug where the route popup was showing distance in kilometers instead of miles. #490 + +### Added + +- A link to the Photoprism photos on the trip page if there are any. +- A `orientation` field in the Api::PhotoSerializer, hence the `GET /api/v1/photos` endpoint now includes the orientation of the photo. Valid values are `portrait` and `landscape`. +- Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI. +- `DISABLE_TELEMETRY` env var to disable telemetry. More on telemetry: https://dawarich.app/docs/tutorials/telemetry +- `reverse_geocoded_at` column added to the `points` table. + +### Changed + +- On the Stats page, the "Reverse geocoding" section is now showing the number of points that were reverse geocoded based on `reverse_geocoded_at` column, value of which is based on the time when the point was reverse geocoded. If no geodata for the point is available, `reverse_geocoded_at` will be set anyway. Number of points that were reverse geocoded but no geodata is available for them is shown below the "Reverse geocoded" number. + + +# 0.19.3 - 2024-12-06 + +### Changed + +- Refactored stats calculation to calculate only necessary stats, instead of calculating all stats +- Stats are now being calculated every 1 hour instead of 6 hours +- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points. +- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion. + +### Added + +- In-app notification about telemetry being enabled. + +# 0.19.2 - 2024-12-04 + +## The Telemetry release + +Dawarich now can collect usage metrics and send them to InfluxDB. Before this release, the only metrics that could be somehow tracked by developers (only @Freika, as of now) were the number of stars on GitHub and the overall number of docker images being pulled, across all versions of Dawarich, non-splittable by version. New in-app telemetry will allow us to track more granular metrics, allowing me to make decisions based on facts, not just guesses. + +I'm aware about the privacy concerns, so I want to be very transparent about what data is being sent and how it's used. + +Data being sent: + +- Number of DAU (Daily Active Users) +- App version +- Instance ID (unique identifier of the Dawarich instance built by hashing the api key of the first user in the database) + +The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone. + +Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity. + +The telemetry is enabled by default, but it **can be disabled** by setting `DISABLE_TELEMETRY` env var to `true`. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the [telemetry page](https://dawarich.app/docs/tutorials/telemetry) of the website docs. + +### Added + +- Telemetry feature. It's now collecting usage metrics and sending them to InfluxDB. + +# 0.19.1 - 2024-12-04 + +### Fixed + +- Sidekiq is now being correctly exported to Prometheus with `PROMETHEUS_EXPORTER_ENABLED=true` env var in `dawarich_sidekiq` service. + +# 0.19.0 - 2024-12-04 + +## The Photoprism integration release + +⚠️ This release introduces a breaking change. ⚠️ +The `GET /api/v1/photos` endpoint now returns following structure of the response: + +```json +[ + { + "id": "1", + "latitude": 11.22, + "longitude": 12.33, + "localDateTime": "2024-01-01T00:00:00Z", + "originalFileName": "photo.jpg", + "city": "Berlin", + "state": "Berlin", + "country": "Germany", + "type": "image", // "image" or "video" + "source": "photoprism" // "photoprism" or "immich" + } +] +``` + +### Added + +- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). +- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process. + +### Fixed + +- z-index on maps so they won't overlay notifications dropdown +- Redis connectivity where it's not required + +# 0.18.2 - 2024-11-29 + +### Added + +- Demo account. You can now login with `demo@dawarich.app` / `password` to see how Dawarich works. This replaces previous default credentials. + +### Changed + +- The login page now shows demo account credentials if `DEMO_ENV` env var is set to `true`. + +# 0.18.1 - 2024-11-29 + +### Fixed + +- Fixed a bug where the trips interface was breaking when Immich integration is not configured. + +### Added + +- Flash messages are now being shown on the map when Immich integration is not configured. + +# 0.18.0 - 2024-11-28 + +## The Trips release + +You can now create, edit and delete trips. To create a trip, click on the "New Trip" button on the Trips page. Provide a name, date and time for start and end of the trip. You can add your own notes to the trip as well. + +If you have points tracked during provided timeframe, they will be automatically added to the trip and will be shown on the trip map. + +Also, if you have Immich integrated, you will see photos from the trip on the trip page, along with a link to look at them on Immich. + +### Added + +- The Trips feature. Read above for more details. + +### Changed + +- Maps are now not so rough on the edges. + +# 0.17.2 - 2024-11-27 + +### Fixed + +- Retrieving photos from Immich now using `takenAfter` and `takenBefore` instead of `createdAfter` and `createdBefore`. With `createdAfter` and `createdBefore` Immich was returning no items some years. + +# 0.17.1 - 2024-11-27 + +### Fixed + +- Retrieving photos from Immich now correctly handles cases when Immich returns no items. It also logs the response from Immich for debugging purposes. + +# 0.17.0 - 2024-11-26 + +## The Immich Photos release + +With this release, Dawarich can now show photos from your Immich instance on the map. + +To enable this feature, you need to provide your Immich instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). + +An important note to add here is that photos are heavy and hence generate a lot of traffic. The response from Immich for specific dates is being cached in Redis for 1 day, and that may lead to Redis taking a lot more space than previously. But since the cache is being expired after 24 hours, you'll get your space back pretty soon. + +The other thing worth mentioning is how Dawarich gets data from Immich. It goes like this: + +1. When you click on the "Photos" layer, Dawarich will make a request to `GET /api/v1/photos` endpoint to get photos for the selected timeframe. +2. This endpoint will make a request to `POST /search/metadata` endpoint of your Immich instance to get photos for the selected timeframe. +3. The response from Immich is being cached in Redis for 1 day. +4. Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. The number of requests to this endpoint will depend on how many photos you have in the selected timeframe. +5. For each photo, Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. This thumbnail request is also cached in Redis for 1 day. + + +### Added + +- If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled. +- `GET /api/v1/photos` endpoint added to get photos from Immich. +- `GET /api/v1/photos/:id/thumbnail.jpg` endpoint added to get photo thumbnail from Immich. + +# 0.16.9 - 2024-11-24 + +### Changed + +- Rate limit for the Photon API is now 1 request per second. If you host your own Photon API instance, reverse geocoding requests will not be limited. +- Requests to the Photon API are now have User-Agent header set to "Dawarich #{APP_VERSION} (https://dawarich.app)" + +# 0.16.8 - 2024-11-20 + +### Changed + +- Default number of Puma workers is now 2 instead of 1. This should improve the performance of the application. If you have a lot of users, you might want to increase the number of workers. You can do this by setting the `WEB_CONCURRENCY` env var in your `docker-compose.yml` file. Example: + +```diff + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + environment: + ... + WEB_CONCURRENCY: "2" +``` + +# 0.16.7 - 2024-11-20 + +### Changed + +- Prometheus exporter is now bound to 0.0.0.0 instead of localhost +- `PROMETHEUS_EXPORTER_HOST` and `PROMETHEUS_EXPORTER_PORT` env vars were added to the `docker-compose.yml` file to allow you to set the host and port for the Prometheus exporter. They should be added to both `dawarich_app` and `dawarich_sidekiq` services Example: + +```diff + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + environment: + ... + PROMETHEUS_EXPORTER_ENABLED: "true" ++ PROMETHEUS_EXPORTER_HOST: 0.0.0.0 ++ PROMETHEUS_EXPORTER_PORT: "9394" + + dawarich_sidekiq: + image: freikin/dawarich:latest + container_name: dawarich_sidekiq + environment: + ... + PROMETHEUS_EXPORTER_ENABLED: "true" ++ PROMETHEUS_EXPORTER_HOST: dawarich_app ++ PROMETHEUS_EXPORTER_PORT: "9394" +``` + +# 0.16.6 - 2024-11-20 + +### Added + +- Dawarich now can export metrics to Prometheus. You can find the metrics at `your.host:9394/metrics` endpoint. The metrics are being exported in the Prometheus format and can be scraped by Prometheus server. To enable exporting, set the `PROMETHEUS_EXPORTER_ENABLED` env var in your docker-compose.yml to `true`. Example: + +```yaml + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + environment: + ... + PROMETHEUS_EXPORTER_ENABLED: "true" +``` + +# 0.16.5 - 2024-11-18 + +### Changed + +- Dawarich now uses `POST /api/search/metadata` endpoint to get geodata from Immich. + +# 0.16.4 - 2024-11-12 + +### Added + +- Admins can now see all users in the system on the Users page. The path is `/settings/users`. + +### Changed + +- Admins can now provide custom password for new users and update passwords for existing users on the Users page. +- The `bin/dev` file will no longer run `bin/rails tailwindcss:watch` command. It's useful only for development and doesn't really make sense to run it in production. + +### Fixed + +- Exported files will now always have an extension when downloaded. Previously, the extension was missing in case of GPX export. +- Deleting and sorting points on the Points page will now preserve filtering and sorting params when points are deleted or sorted. Previously, the page was being reloaded and filtering and sorting params were lost. + +# 0.16.3 - 2024-11-10 + +### Fixed + +- Make ActionCable respect REDIS_URL env var. Previously, ActionCable was trying to connect to Redis on localhost. + +# 0.16.2 - 2024-11-08 + +### Fixed + +- Exported GPX file now being correctly recognized as valid by Garmin Connect, Adobe Lightroom and (probably) other services. Previously, the exported GPX file was not being recognized as valid by these services. + +# 0.16.1 - 2024-11-08 + +### Fixed + +- Speed is now being recorded into points when a GPX file is being imported. Previously, the speed was not being recorded. +- GeoJSON file from GPSLogger now can be imported to Dawarich. Previously, the import was failing due to incorrect parsing of the file. + +### Changed + +- The Vists suggestion job is disabled. It will be re-enabled in the future with a new approach to the visit suggestion process. + +# 0.16.0 - 2024-11-07 + +## The Websockets release + +### Added + +- New notifications are now being indicated with a blue-ish dot in the top right corner of the screen. Hovering over the bell icon will show you last 10 notifications. +- New points on the map will now be shown in real-time. No need to reload the map to see new points. +- User can now enable or disable Live Mode in the map controls. When Live Mode is enabled, the map will automatically scroll to the new points as they are being added to the map. + +### Changed + +- Scale on the map now shows the distance both in kilometers and miles. + +# 0.15.13 - 2024-11-01 + +### Added + +- `GET /api/v1/countries/borders` endpoint to get countries for scratch map feature + +# 0.15.12 - 2024-11-01 + +### Added + +- Scratch map. You can enable it in the map controls. The scratch map highlight countries you've visited. The scratch map is working properly only if you have your points reverse geocoded. + +# 0.15.11 - 2024-10-29 + +### Added + +- Importing Immich data on the Imports page now will trigger an attempt to write raw json file with the data from Immich to `tmp/imports/immich_raw_data_CURRENT_TIME_USER_EMAIL.json` file. This is useful to debug the problem with the import if it fails. #270 + +### Fixed + +- New app version is now being checked every 6 hours instead of 1 day and the check is being performed in the background. #238 + +### Changed + +- ⚠️ The instruction to import `Records.json` from Google Takeout now mentions `tmp/imports` directory instead of `public/imports`. ⚠️ #326 +- Hostname definition for Sidekiq healtcheck to solve #344. See the diff: + +```diff + dawarich_sidekiq: + image: freikin/dawarich:latest + container_name: dawarich_sidekiq + healthcheck: +- test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep $(hostname)" ] ++ test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep ${HOSTNAME}" ] +``` + +- Renamed directories used by app and sidekiq containers for gems cache to fix #339: + +```diff + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_sidekiq + volumes: +- - gem_cache:/usr/local/bundle/gems ++ - gem_cache:/usr/local/bundle/gems_app + +... + + dawarich_sidekiq: + image: freikin/dawarich:latest + container_name: dawarich_sidekiq + volumes: +- - gem_cache:/usr/local/bundle/gems ++ - gem_cache:/usr/local/bundle/gems_sidekiq +``` + +# 0.15.10 - 2024-10-25 + +### Fixed + +- Data migration that prevented the application from starting. + +# 0.15.9 - 2024-10-24 + +### Fixed + +- Stats distance calculation now correctly calculates the daily distances. + +### Changed + +- Refactored the stats calculation process to make it more efficient. + +# 0.15.8 - 2024-10-22 + +### Added + +- User can now select between "Raw" and "Simplified" mode in the map controls. "Simplified" mode will show less points, improving the map performance. "Raw" mode will show all points. + +# 0.15.7 - 2024-10-19 + +### Fixed + +- A bug where "RuntimeError: failed to get urandom" was being raised upon importing attempt on Synology. + +# 0.15.6 - 2024-10-19 + +### Fixed + +- Import of Owntracks' .rec files now correctly imports points. Previously, the import was failing due to incorrect parsing of the file. + +# 0.15.5 - 2024-10-16 + +### Fixed + +- Fixed a bug where Google Takeout import was failing due to unsupported date format with milliseconds in the file. +- Fixed a bug that prevented using the Photon API host with http protocol. Now you can use both http and https protocols for the Photon API host. You now need to explicitly provide `PHOTON_API_USE_HTTPS` to be `true` or `false` depending on what protocol you want to use. [Example](https://github.com/Freika/dawarich/blob/master/docker-compose.yml#L116-L117) is in the `docker-compose.yml` file. + +### Changed + +- The Map page now by default uses timeframe based on last point tracked instead of the today's points. If there are no points, the map will use the today's timeframe. +- The map on the Map page can no longer be infinitely scrolled horizontally. #299 + +# 0.15.4 - 2024-10-15 + +### Changed + +- Use static version of `geocoder` library that supports http and https for Photon API host. This is a temporary solution until the change is available in a stable release. + +### Added + +- Owntracks' .rec files now can be imported to Dawarich. The import process is the same as for other kinds of files, just select the .rec file and choose "owntracks" as a source. + +### Removed + +- Owntracks' .json files are no longer supported for import as Owntracks itself does not export to this format anymore. + +# 0.15.3 - 2024-10-05 + +To expose the watcher functionality to the user, a new directory `/tmp/imports/watched/` was created. Add new volume to the `docker-compose.yml` file to expose this directory to the host machine. + +```diff + ... + + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + volumes: + - gem_cache:/usr/local/bundle/gems + - public:/var/app/public ++ - watched:/var/app/tmp/watched + + ... + + dawarich_sidekiq: + image: freikin/dawarich:latest + container_name: dawarich_sidekiq + volumes: + - gem_cache:/usr/local/bundle/gems + - public:/var/app/public ++ - watched:/var/app/tmp/watched + + ... + +volumes: + db_data: + gem_cache: + shared_data: + public: ++ watched: +``` + +### Changed + +- Watcher now looks into `/tmp/imports/watched/USER@EMAIL.TLD` directory instead of `/tmp/imports/watched/` to allow using arbitrary file names for imports + +# 0.15.1 - 2024-10-04 + +### Added + +- `linux/arm/v7` is added to the list of supported architectures to support Raspberry Pi 4 and other ARMv7 devices + +# 0.15.0 - 2024-10-03 + +## The Watcher release + +The /public/imporst/watched/ directory is watched by Dawarich. Any files you put in this directory will be imported into the database. The name of the file must start with an email of the user you want to import the file for. The email must be followed by an underscore symbol (_) and the name of the file. + +For example, if you want to import a file for the user with the email address "email@dawarich.app", you would name the file "email@dawarich.app_2024-05-01_2024-05-31.gpx". The file will be imported into the database and the user will receive a notification in the app. + +Both GeoJSON and GPX files are supported. + + +### Added + +- You can now put your GPX and GeoJSON files to `tmp/imports/watched` directory and Dawarich will automatically import them. This is useful if you have a service that can put files to the directory automatically. The directory is being watched every 60 minutes for new files. + +### Changed + +- Monkey patch for Geocoder to support http along with https for Photon API host was removed becausee it was breaking the reverse geocoding process. Now you can use only https for the Photon API host. This might be changed in the future +- Disable retries for some background jobs + +### Fixed + +- Stats update is now being correctly triggered every 6 hours + +# [0.14.7] - 2024-10-01 + +### Fixed + +- Now you can use http protocol for the Photon API host if you don't have SSL certificate for it +- For stats, total distance per month might have been not equal to the sum of distances per day. Now it's fixed and values are equal +- Mobile view of the map looks better now + + +### Changed + +- `GET /api/v1/points` can now accept optional `?order=asc` query parameter to return points in ascending order by timestamp. `?order=desc` is still available to return points in descending order by timestamp +- `GET /api/v1/points` now returns `id` attribute for each point + +# [0.14.6] - 2024-29-30 + +### Fixed + +- Points imported from Google Location History (mobile devise) now have correct timestamps + +### Changed + +- `GET /api/v1/points?slim=true` now returns `id` attribute for each point + +# [0.14.5] - 2024-09-28 + +### Fixed + +- GPX export now finishes correctly and does not throw an error in the end +- Deleting points from the Points page now preserves `start_at` and `end_at` values for the routes. #261 +- Visits map now being rendered correctly in the Visits page. #262 +- Fixed issue with timezones for negative UTC offsets. #194, #122 +- Point page is no longer reloads losing provided timestamps when searching for points on Points page. #283 + +### Changed + +- Map layers from Stadia were disabled for now due to necessary API key + +# [0.14.4] - 2024-09-24 + +### Fixed + +- GPX export now has time and elevation elements for each point + +### Changed + +- `GET /api/v1/points` will no longer return `raw_data` attribute for each point as it's a bit too much + +### Added + +- "Slim" version of `GET /api/v1/points`: pass optional param `?slim=true` to it and it will return only latitude, longitude and timestamp + + +# [0.14.3] — 2024-09-21 + +### Fixed + +- Optimize order of the dockerfiles to leverage layer caching by @JoeyEamigh +- Add support for alternate postgres ports and db names in docker by @JoeyEamigh +- Creating exports directory if it doesn't exist by @tetebueno + + +## [0.14.1] — 2024-09-16 + +### Fixed + +- Fixed a bug where the map was not loading due to invalid tile layer name + + +## [0.14.0] — 2024-09-15 + +### Added + +- 17 new tile layers to choose from. Now you can select the tile layer that suits you the best. You can find the list of available tile layers in the map controls in the top right corner of the map under the layers icon. + + +## [0.13.7] — 2024-09-15 + +### Added + +- `GET /api/v1/points` response now will include `X-Total-Pages` and `X-Current-Page` headers to make it easier to work with the endpoint +- The Pages point now shows total number of points found for provided date range + +## Fixed + +- Link to Visits page in notification informing about new visit suggestion + + +## [0.13.6] — 2024-09-13 + +### Fixed + +- Flatten geodata retrieved from Immich before processing it to prevent errors + + +## [0.13.5] — 2024-09-08 + +### Added + +- Links to view import points on the map and on the Points page on the Imports page. + +### Fixed + +- The Imports page now loading faster. + +### Changed + +- Default value for `RAILS_MAX_THREADS` was changed to 10. +- Visit suggestions background job was moved to its own low priority queue to prevent it from blocking other jobs. + + +## [0.13.4] — 2024-09-06 + +### Fixed + +- Fixed a bug preventing the application from starting, when there is no users in the database but a data migration tries to update one. + + +## [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" +``` + +### Changed + +- Default time range on the map is now 1 day instead of 1 month. It will help you with performance issues if you have a lot of points in the database. + + +## [0.13.2] — 2024-09-06 + +### Fixed + +- GeoJSON import now correctly imports files with FeatureCollection as a root object + +### Changed + +- The Points page now have number of points found for provided date range + +## [0.13.1] — 2024-09-05 + +### Added + +- `GET /api/v1/health` endpoint to check the health of the application with swagger docs + +### Changed + +- Ruby version updated to 3.3.4 +- Visits suggestion process now will try to merge consecutive visits to the same place into one visit. + + +## [0.13.0] — 2024-09-03 + +The GPX and GeoJSON export release + +⚠️ BREAKING CHANGES: ⚠️ + +Default exporting format is now GeoJSON instead of Owntracks-like JSON. This will allow you to use the exported data in other applications that support GeoJSON format. It's also important to highlight, that GeoJSON format does not describe a way to store any time-related data. Dawarich relies on the `timestamp` field in the GeoJSON format to determine the time of the point. The value of the `timestamp` field should be a Unix timestamp in seconds. If you import GeoJSON data that does not have a `timestamp` field, the point will not be imported. + +Example of a valid point in GeoJSON format: + +```json +{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.350110811262352, 52.51450815] + }, + "properties": { + "timestamp": 1725310036 + } +} +``` + +### Added + +- GeoJSON format is now available for exporting data. +- GPX format is now available for exporting data. +- Importing GeoJSON is now available. + +### Changed + +- Default exporting format is now GeoJSON instead of Owntracks-like JSON. This will allow you to use the exported data in other applications that support GeoJSON format. + +### Fixed + +- Fixed a bug where the confirmation alert was shown more than once when deleting a point. + + +## [0.12.3] — 2024-09-02 + +### Added + +- Resource limits to docke-compose.yml file to prevent server overload. Feel free to adjust the limits to your needs. + +```yml +deploy: + resources: + limits: + cpus: '0.50' # Limit CPU usage to 50% of one core + memory: '2G' # Limit memory usage to 2GB +``` + +### Fixed + +- Importing geodata from Immich will now not throw an error in the end of the process + +### Changed + +- A notification about an existing import with the same name will now show the import name +- Export file now also will contain `raw_dat` field for each point. This field contains the original data that was imported to the application. + + +## [0.12.2] — 2024-08-28 + +### Added + +- `PATCH /api/v1/settings` endpoint to update user settings with swagger docs +- `GET /api/v1/settings` endpoint to get user settings with swagger docs +- Missing `page` and `per_page` query parameters to the `GET /api/v1/points` endpoint swagger docs + +### Changed + +- Map settings moved to the map itself and are available in the top right corner of the map under the gear icon. + + +## [0.12.1] — 2024-08-25 + +### Fixed + +- Fixed a bug that prevented data migration from working correctly + +## [0.12.0] — 2024-08-25 + +### The visit suggestion release + +1. With this release deployment, data migration will work, starting visits suggestion process for all users. +2. After initial visit suggestion process, new suggestions will be calculated every 24 hours, based on points for last 24 hours. +3. If you have enabled reverse geocoding and (optionally) provided Photon Api Host, Dawarich will try to reverse geocode your visit and suggest specific places you might have visited, such as cafes, restaurants, parks, etc. If reverse geocoding is not enabled, or Photon Api Host is not provided, Dawarich will not try to suggest places but you'll be able to rename the visit yourself. +4. You can confirm or decline the visit suggestion. If you confirm the visit, it will be added to your timeline. If you decline the visit, it will be removed from your timeline. You'll be able to see all your confirmed, declined and suggested visits on the Visits page. + + +### Added + +- A "Map" button to each visit on the Visits page to allow user to see the visit on the map +- Visits suggestion functionality. Read more on that in the release description +- Click on the visit name allows user to rename the visit +- Tabs to the Visits page to allow user to switch between confirmed, declined and suggested visits +- Places page to see and delete places suggested by Dawarich's visit suggestion process +- Importing a file will now trigger the visit suggestion process for the user + +## [0.11.2] — 2024-08-22 + +### Changed + +### Fixed + +- Dawarich export was failing when attempted to be imported back to Dawarich. +- Imports page with a lot of imports should now load faster. + + +## [0.11.1] — 2024-08-21 + +### Changed + +- `/api/v1/points` endpoint now returns 100 points by default. You can specify the number of points to return by passing the `per_page` query parameter. Example: `/api/v1/points?per_page=50` will return 50 points. Also, `page` query parameter is now available to paginate the results. Example: `/api/v1/points?per_page=50&page=2` will return the second page of 50 points. + +## [0.11.0] — 2024-08-21 + +### Added + +- A user can now trigger the import of their geodata from Immich to Dawarich by clicking the "Import Immich data" button in the Imports page. +- A user can now provide a url and an api key for their Immich instance and then trigger the import of their geodata from Immich to Dawarich. This can be done in the Settings page. + +### Changed + +- Table columns on the Exports page were reordered to make it more user-friendly. +- Exports are now being named with this pattern: "export_from_dd.mm.yyyy_to_dd.mm.yyyy.json" where "dd.mm.yyyy" is the date range of the export. +- Notification about any error now will include the stacktrace. + +## [0.10.0] — 2024-08-20 + +### Added + +- The `api/v1/stats` endpoint to get stats for the user with swagger docs + +### Fixed + +- Redis and DB containers are now being automatically restarted if they fail. Update your `docker-compose.yml` if necessary + +```diff + services: + dawarich_redis: + image: redis:7.0-alpine + command: redis-server + networks: + - dawarich + volumes: + - shared_data:/var/shared/redis ++ restart: always + dawarich_db: + image: postgres:14.2-alpine + container_name: dawarich_db + volumes: + - db_data:/var/lib/postgresql/data + - shared_data:/var/shared + networks: + - dawarich + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password ++ restart: always +``` + + +See the [PR](https://github.com/Freika/dawarich/pull/185) or Swagger docs (`/api-docs`) for more information. + +## [0.9.12] — 2024-08-15 + +### Fixed + +- Owntracks points are now being saved to the database with the full attributes +- Existing owntracks points also filled with missing data +- Definition of "reverse geocoded points" is now correctly based on the number of points that have full reverse geocoding data instead of the number of points that have only country and city +- Fixed a bug in gpx importing scipt ([thanks, bluemax!](https://github.com/Freika/dawarich/pull/126)) + +## [0.9.11] — 2024-08-14 + +### Fixed + +- A bug where an attempt to import a Google's Records.json file was failing due to wrong object being passed to a background worker + +## [0.9.10] — 2024-08-14 + +### Added + +- PHOTON_API_HOST env variable to set the host of the Photon API. It will allow you to use your own Photon API instance instead of the default one. + +## [0.9.9] — 2024-07-30 + +### Added + +- Pagination to exports page +- Pagination to imports page +- GET `/api/v1/points` endpoint to get all points for the user with swagger docs +- DELETE `/api/v1/points/:id` endpoint to delete a single point for the user with swagger docs +- DELETE `/api/v1/areas/:id` swagger docs +- User can now change route opacity in settings +- Points on the Points page can now be ordered by oldest or newest points +- Visits on the Visits page can now be ordered by oldest or newest visits + +### Changed + +- Point deletion is now being done using an api key instead of CSRF token + +### Fixed + +- OpenStreetMap layer is now being selected by default in map controls + +--- + +## [0.9.8] — 2024-07-27 + +### Fixed + +- Call to the background job to calculate visits + +--- + +## [0.9.7] — 2024-07-27 + +### Fixed + +- Name of background job to calculate visits + +--- + +## [0.9.6] — 2024-07-27 + +### Fixed + +- Map areas functionality + +--- + +## [0.9.5] — 2024-07-27 + +### Added + +- A possibility to create areas. To create an area, click on the Areas checkbox in map controls (top right corner of the map), then in the top left corner of the map, click on a small circle icon. This will enable draw tool, allowing you to draw an area. When you finish drawing, release the mouse button, and the area will be created. Click on the area, set the name and click "Save" to save the area. You can also delete the area by clicking on the trash icon in the area popup. +- A background job to calculate your visits. This job will calculate your visits based on the areas you've created. +- Visits page. This page will show you all your visits, calculated based on the areas you've created. You can see the date and time of the visit, the area you've visited, and the duration of the visit. +- A possibility to confirm or decline a visit. When you create an area, the visit is not calculated immediately. You need to confirm or decline the visit. You can do this on the Visits page. Click on the visit, then click on the "Confirm" or "Decline" button. If you confirm the visit, it will be added to your timeline. If you decline the visit, it will be removed from your timeline. +- Settings for visit calculation. You can set the minimum time spent in the area to consider it as a visit. This setting can be found in the Settings page. +- POST `/api/v1/areas` and GET `/api/v1/areas` endpoints. You can now create and list your areas via the API. + +⚠️ Visits functionality is still in beta. If you find any issues, please let me know. ⚠️ + +### Fixed + +- A route popup now correctly shows distance made in the route, not the distance between first and last points in the route. + +--- + +## [0.9.4] — 2024-07-21 + +### Added + +- A popup being shown when user clicks on a point now contains a link to delete the point. This is useful if you want to delete a point that was imported by mistake or you just want to clean up your data. + +### Fixed + +- Added `public/imports` and `public/exports` folders to git to prevent errors when exporting data + +### Changed + +- Some code from `maps_controller.js` was extracted into separate files + +--- + + +## [0.9.3] — 2024-07-19 + +### Added + +- Admin flag to the database. Now not only the first user in the system can create new users, but also users with the admin flag set to true. This will make easier introduction of more admin functions in the future. + +### Fixed + +- Route hover distance is now being rendered in kilometers, not in meters, if route distance is more than 1 km. + +--- + +## [0.9.2] — 2024-07-19 + +### Fixed + +- Hover over a route does not move map anymore and shows the route tooltip where user hovers over the route, not at the end of the route. Click on route now will move the map to include the whole route. + +--- + +## [0.9.1] — 2024-07-12 + +### Fixed + +- Fixed a bug where total reverse geocoded points were calculated based on number of *imported* points that are reverse geocoded, not on the number of *total* reverse geocoded points. + +--- + +## [0.9.0] — 2024-07-12 + +### Added + +- Background jobs page. You can find it in Settings -> Background Jobs. +- Queue clearing buttons. You can clear all jobs in the queue. +- Reverse geocoding restart button. You can restart the reverse geocoding process for all of your points. +- Reverse geocoding continue button. Click on this button will start reverse geocoding process only for points that were not processed yet. +- A lot more data is now being saved in terms of reverse geocoding process. It will be used in the future to create more insights about your data. + +### Changed + +- Point reference to a user is no longer optional. It should not cause any problems, but if you see any issues, please let me know. +- ⚠️ Calculation of total reverse geocoded points was changed. ⚠️ Previously, the reverse geocoding process was recording only country and city for each point. Now, it records all the data that was received from the reverse geocoding service. This means that the total number of reverse geocoded points will be different from the previous one. It is recommended to restart the reverse geocoding process to get this data for all your existing points. Below you can find an example of what kind of data is being saved to your Dawarich database: + +```json +{ + "place_id": 127850637, + "licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright", + "osm_type": "way", + "osm_id": 718035022, + "lat": "52.51450815", + "lon": "13.350110811262352", + "class": "historic", + "type": "monument", + "place_rank": 30, + "importance": 0.4155071896625501, + "addresstype": "historic", + "name": "Victory Column", + "display_name": "Victory Column, Großer Stern, Botschaftsviertel, Tiergarten, Mitte, Berlin, 10785, Germany", + "address": { + "historic": "Victory Column", + "road": "Großer Stern", + "neighbourhood": "Botschaftsviertel", + "suburb": "Tiergarten", + "borough": "Mitte", + "city": "Berlin", + "ISO3166-2-lvl4": "DE-BE", + "postcode": "10785", + "country": "Germany", + "country_code": "de" + }, + "boundingbox": [ + "52.5142449", + "52.5147775", + "13.3496725", + "13.3505485" + ] +} +``` + +--- + +## [0.8.7] — 2024-07-09 + +### Changed + +- Added a logging config to the `docker-compose.yml` file to prevent logs from overflowing the disk. Now logs are being rotated and stored in the `log` folder in the root of the application. You can find usage example in the the repository's `docker-compose.yml` [file](https://github.com/Freika/dawarich/blob/master/docker-compose.yml#L50). Make sure to add this config to both `dawarich_app` and `dawarich_sidekiq` services. + +```yaml + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" +``` + +### Fixed + +- Visiting notifications page now marks this notifications as read + +--- + +## [0.8.6] — 2024-07-08 + +### Added + +- Guide on how to setup a reverse proxy for Dawarich in the `docs/how_to_setup_reverse_proxy.md` file. This guide explains how to set up a reverse proxy for Dawarich using Nginx and Apache2. + +### Removed + +- `MAP_CENTER` env var from the `docker-compose.yml` file. This variable was used to set the default center of the map, but it is not needed anymore, as the map center is now hardcoded in the application. ⚠️ Feel free to remove this variable from your `docker-compose.yml` file. ⚠️ + +### Fixed + +- Fixed a bug where Overland batch payload was not being processed due to missing coordinates in the payload. Now, if the coordinates are missing, the single point is skipped and the rest are being processed. + +--- + +## [0.8.5] — 2024-07-08 + +### Fixed + +- Set `'localhost'` string as a default value for `APPLICATION_HOSTS` environment variable in the `docker-compose.yml` file instead of an array. This is necessary to prevent errors when starting the application. + +--- + +## [0.8.4] — 2024-07-08 + +### Added + +- Support for multiple hosts. Now you can specify the host of the application by setting the `APPLICATION_HOSTS` (note plural form) environment variable in the `docker-compose.yml` file. Example: + +```yaml + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + environment: + APPLICATION_HOSTS: "yourhost.com,www.yourhost.com,127.0.0.1" +``` + +Note, there should be no protocol prefixes in the `APPLICATION_HOSTS` variable, only the hostnames. + +⚠️ It would also be better to migrate your current `APPLICATION_HOST` to `APPLICATION_HOSTS` to avoid any issues in the future, as `APPLICATION_HOST` will be deprecated in the nearest future. ⚠️ + +- Support for HTTPS. Now you can specify the protocol of the application by setting the `APPLICATION_PROTOCOL` environment variable in the `docker-compose.yml` file. Default value is `http` Example: + +```yaml + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + environment: + APPLICATION_PROTOCOL: "https" +``` + +### Fixed + +- Support for a `location-history.json` file from Google Takeout. It turned out, this file could contain not only an object with location data history, but also an array of objects with location data history. Now Dawarich can handle both cases and import the data correctly. + + +--- + +## [0.8.3] — 2024-07-03 + +### Added + +- Notifications system. Now you will receive a notification when an import or export is finished, when stats update is completed and if any error occurs during any of these processes. Notifications are displayed in the top right corner of the screen and are stored in the database. You can see all your notifications on the Notifications page. +- Swagger API docs for `/api/v1/owntracks/points`. You can find the API docs at `/api-docs`. + +--- + +## [0.8.2] — 2024-06-30 + +### Added + +- Google Takeout geodata, taken from a [mobile devise](https://support.google.com/maps/thread/264641290/export-full-location-timeline-data-in-json-or-similar-format-in-the-new-version-of-timeline?hl=en), is now fully supported and can be imported to the Dawarich. The import process is the same as for other kinds of files, just select the JSON file and choose "Google Phone Takeout" as a source. + +### Fixed + +- Fixed a bug where an imported point was not being saved to the database if a point with the same timestamp and already existed in the database even if it was other user's point. + +--- + +## [0.8.1] — 2024-06-30 + +### Added + +- First user in the system can now create new users from the Settings page. This is useful for creating new users without the need to enable registrations. Default password for new users is `password`. + +### Changed + +- Registrations are now disabled by default. On the initial setup, a default user with email `user@domain.com` and password `password` is created. You can change the password in the Settings page. +- On the Imports page, now you can see the real number of points imported. Previously, this number might have not reflect the real number of points imported. + +--- + +## [0.8.0] — 2024-06-25 + +### Added + +- New Settings page to change Dawarich settings. +- New "Fog of War" toggle on the map controls. +- New "Fog of War meters" field in Settings. This field allows you to set the radius in meters around the point to be shown on the map. The map outside of this radius will be covered with a fog of war. + +### Changed + +- Order of points on Points page is now descending by timestamp instead of ascending. + +--- + +## [0.7.1] — 2024-06-20 + +In new Settings page you can now change the following settings: + +- Maximum distance between two points to consider them as one route +- Maximum time between two points to consider them as one route + +### Added + +- New Settings page to change Dawarich settings. + +### Changed + +- Settings link in user menu now redirects to the new Settings page. +- Old settings page is now available undeer Account link in user menu. + +--- + +## [0.7.0] — 2024-06-19 + +## The GPX MVP Release + +This release introduces support for GPX files to be imported. Now you can import GPX files from your devices to Dawarich. The import process is the same as for other kinds of files, just select the GPX file instead and choose "gpx" as a source. Both single-segmented and multi-segmented GPX files are supported. + +⚠️ BREAKING CHANGES: ⚠️ + +- `/api/v1/points` endpoint is removed. Please use `/api/v1/owntracks/points` endpoint to upload your points from OwnTracks mobile app instead. + +### Added + +- Support for GPX files to be imported. + +### Changed + +- Couple of unnecessary params were hidden from route popup and now can be shown using `?debug=true` query parameter. This is useful for debugging purposes. + +### Removed + +- `/exports/download` endpoint is removed. Now you can download your exports directly from the Exports page. +- `/api/v1/points` endpoint is removed. + +--- + +## [0.6.4] — 2024-06-18 + +### Added + +- A link to Dawarich's website in the footer. It ain't much, but it's honest work. + +### Fixed + +- Fixed version badge in the navbar. Now it will show the correct version of the application. + +### Changed + +- Default map center location was changed. + +--- + +## [0.6.3] — 2024-06-14 + +⚠️ IMPORTANT: ⚠️ + +Please update your `docker-compose.yml` file to include the following changes: + +```diff + dawarich_sidekiq: + image: freikin/dawarich:latest + container_name: dawarich_sidekiq + volumes: + - gem_cache:/usr/local/bundle/gems ++ - public:/var/app/public +``` + +### Added + +- Added a line with public volume to sidekiq's docker-compose service to allow sidekiq process to write to the public folder + +### Fixed + +- Fixed a bug where the export file was not being created in the public folder + +--- + +## [0.6.2] — 2024-06-14 + +This is a debugging release. No changes were made to the application. + +--- + +## [0.6.0] — 2024-06-12 + +### Added + +- Exports page to list existing exports download them or delete them + +### Changed + +- Exporting process now is done in the background, so user can close the browser tab and come back later to download the file. The status of the export can be checked on the Exports page. + +ℹ️ Deleting Export file will only delete the file, not the points in the database. ℹ️ + +⚠️ BREAKING CHANGES: ⚠️ + +Volume, exposed to the host machine for placing files to import was changed. See the changes below. + +Path for placing files to import was changed from `tmp/imports` to `public/imports`. + +```diff + ... + + dawarich_app: + image: freikin/dawarich:latest + container_name: dawarich_app + volumes: + - gem_cache:/usr/local/bundle/gems +- - tmp:/var/app/tmp ++ - public:/var/app/public/imports + + ... +``` + +```diff + ... + +volumes: + db_data: + gem_cache: + shared_data: +- tmp: ++ public: +``` + +--- + +## [0.5.3] — 2024-06-10 + +### Added + +- A data migration to remove points with 0.0, 0.0 coordinates. This is necessary to prevent errors when calculating distance in Stats page. + +### Fixed + +- Reworked code responsible for importing "Records.json" file from Google Takeout. Now it is more reliable and faster, and should not throw as many errors as before. + +--- + +## [0.5.2] — 2024-06-08 + +### Added + +- Test version of google takeout importing service for exports from users' phones + +--- + +## [0.5.1] — 2024-06-07 + +### Added + +- Background jobs concurrency now can be set with `BACKGROUND_PROCESSING_CONCURRENCY` env variable in `docker-compose.yml` file. Default value is 10. +- Hand-made favicon + +### Changed + +- Change minutes to days and hours on route popup + +### Fixed + +- Improved speed of "Stats" page loading by removing unnecessary queries + +--- + +## [0.5.0] — 2024-05-31 + +### Added + +- New buttons to quickly move to today's, yesterday's and 7 days data on the map +- "Download JSON" button to points page +- For debugging purposes, now user can use `?meters_between_routes=500` and `?minutes_between_routes=60` query parameters to set the distance and time between routes to split them on the map. This is useful to understand why routes might not be connected on the map. +- Added scale indicator to the map + +### Changed + +- Removed "Your data" page as its function was replaced by "Download JSON" button on the points page +- Hovering over a route now also shows time and distance to next route as well as time and distance to previous route. This allows user to understand why routes might not be connected on the map. + +--- + +## [0.4.3] — 2024-05-30 + +### Added + +- Now user can hover on a route and see when it started, when it ended and how much time it took to travel + +### Fixed + +- Timestamps in export form are now correctly assigned from the first and last points tracked by the user +- Routes are now being split based both on distance and time. If the time between two consecutive points is more than 60 minutes, the route is split into two separate routes. This improves visibility of the routes on the map. + +--- + +## [0.4.2] — 2024-05-29 + +### Changed + +- Routes are now being split into separate one. If distance between two consecutive points is more than 500 meters, the route is split into two separate routes. This improves visibility of the routes on the map. +- Background jobs concurrency is increased from 5 to 10 to speed up the processing of the points. + +### Fixed + +- Point data, accepted from OwnTracks and Overland, is now being checked for duplicates. If a point with the same timestamp and coordinates already exists in the database, it will not be saved. + +--- +## [0.4.1] — 2024-05-25 + +### Added + +- Heatmap layer on the map to show the density of points + +--- + +## [0.4.0] — 2024-05-25 + +**BREAKING CHANGES**: + +- `/api/v1/points` is still working, but will be **deprecated** in nearest future. Please use `/api/v1/owntracks/points` instead. +- All existing points recorded directly to the database via Owntracks or Overland will be attached to the user with id 1. + +### Added + +- Each user now have an api key, which is required to make requests to the API. You can find your api key in your profile settings. +- You can re-generate your api key in your profile settings. +- In your user profile settings you can now see the instructions on how to use the API with your api key for both OwnTracks and Overland. +- Added docs on how to use the API with your api key. Refer to `/api-docs` for more information. +- `POST /api/v1/owntracks/points` endpoint. +- Points are now being attached to a user directly, so you can only see your own points and no other users of your applications can see your points. + +### Changed + +- `/api/v1/overland/batches` endpoint now requires an api key to be passed in the url. You can find your api key in your profile settings. +- All existing points recorded directly to the database will be attached to the user with id 1. +- All stats and maps are now being calculated and rendered based on the user's points only. +- Default `TIME_ZONE` environment variable is now set to 'UTC' in the `docker-compose.yml` file. + +### Fixed + +- Fixed a bug where marker on the map was rendering timestamp without considering the timezone. + +--- + +## [0.3.2] — 2024-05-23 + +### Added + +- Docker volume for importing Google Takeout data to the application + +### Changed + +- Instruction on how to import Google Takeout data to the application + +--- + +## [0.3.1] — 2024-05-23 + +### Added + +- Instruction on how to import Google Takeout data to the application + +--- + +## [0.3.0] — 2024-05-23 + +### Added + +- Add Points page to display all the points as a table with pagination to allow users to delete points +- Sidekiq web interface to monitor background jobs is now available at `/sidekiq` +- Now you can choose a date range of points to be exported + +--- + +## [0.2.6] — 2024-05-23 + +### Fixed + +- Stop selecting `raw_data` column during requests to `imports` and `points` tables to improve performance. + +### Changed + +- Rename PointsController to MapController along with all the views and routes + +### Added + +- Add Points page to display all the points as a table with pagination to allow users to delete points + +--- + +## [0.2.5] — 2024-05-21 + +### Fixed + +- Stop ignoring `raw_data` column during requests to `imports` and `points` tables. This was preventing points from being created. + +--- + +## [0.2.4] — 2024-05-19 + +### Added + +- In right sidebar you can now see the total amount of geopoints aside of kilometers traveled + +### Fixed + +- Improved overall performance if the application by ignoring `raw_data` column during requests to `imports` and `points` tables. + +--- + + +## [0.2.3] — 2024-05-18 + +### Added + +- Now you can import `records.json` file from your Google Takeout archive, not just Semantic History Location JSON files. The import process is the same as for Semantic History Location JSON files, just select the `records.json` file instead and choose "google_records" as a source. + +--- + + +## [0.2.2] — 2024-05-18 + +### Added + +- Swagger docs, can be found at `https:/api-docs` + +--- + +## [0.2.1] — 2024-05-18 + +### Added + +- Cities, visited by user and listed in right sidebar now also have an active link to a date they were visited + +### Fixed + +- Dark/light theme switcher in navbar is now being saved in user settings, so it persists between sessions + +--- + +## [0.2.0] — 2024-05-05 + +*Breaking changes:* + +This release changes how Dawarich handles a city visit threshold. Previously, the `MINIMUM_POINTS_IN_CITY` environment variable was used to determine the minimum *number of points* in a city to consider it as visited. Now, the `MIN_MINUTES_SPENT_IN_CITY` environment variable is used to determine the minimum *minutes* between two points to consider them as visited the same city. + +The logic behind this is the following: if you have a lot of points in a city, it doesn't mean you've spent a lot of time there, especially if your OwnTracks app was in "Move" mode. So, it's better to consider the time spent in a city rather than the number of points. + +In your docker-compose.yml file, you need to replace the `MINIMUM_POINTS_IN_CITY` environment variable with `MIN_MINUTES_SPENT_IN_CITY`. The default value is `60`, in minutes. + +--- + +## [0.1.9] — 2024-04-25 + +### Added + +- A test for CheckAppVersion service class + +### Changed + +- Replaced ActiveStorage with Shrine for file uploads + +### Fixed + +- `ActiveStorage::FileNotFoundError` error when uploading export files + +--- + +## [0.1.8.1] — 2024-04-21 + +### Changed + +- Set Redis as default cache store + +### Fixed + +- Consider timezone when parsing datetime params in points controller +- Add rescue for check version service class + +--- + +## [0.1.8] — 2024-04-21 + +### Added + +- Application version badge to the navbar with check for updates button +- Npm dependencies install to Github build workflow +- Footer + +### Changed + +- Disabled map points rendering by default to improve performance on big datasets + +--- + +## [0.1.7] — 2024-04-17 + +### Added + +- Map controls to toggle polylines and points visibility + +### Changed + +- Added content padding for mobile view +- Fixed stat card layout for mobile view + +--- + +## [0.1.6.3] — 2024-04-07 + +### Changed + +- Removed strong_params from POST /api/v1/points + +--- + +## [0.1.6.1] — 2024-04-06 + +### Fixed + +- `ActiveStorage::FileNotFoundError: ActiveStorage::FileNotFoundError` error when uploading export files + +--- + +## [0.1.6] — 2024-04-06 + +You can now use [Overland](https://overland.p3k.app/) mobile app to track your location. + +### Added + +- Overland API endpoint (POST /api/v1/overland/batches) + +### Changed + +### Fixed + +--- + +## [0.1.5] — 2024-04-05 + +You can now specify the host of the application by setting the `APPLICATION_HOST` environment variable in the `docker-compose.yml` file. + +### Added + +- Added version badge to navbar +- Added APPLICATION_HOST environment variable to docker-compose.yml to allow user to specify the host of the application +- Added CHANGELOG.md to keep track of changes + +### Changed + +- Specified gem version in Docker entrypoint + +### Fixed + +points.update_all("velocity = CAST(ROUND(CAST((CAST(velocity AS FLOAT) * 1000 / 3600) AS NUMERIC), 1) AS TEXT)") + + +``` + +This will convert speed in kilometers per hour to meters per second. + ### Fixed - After deleting one point from the map, other points can now be deleted as well. #723 #678 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 982d94b0..4016ec54 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -107,3 +107,8 @@ transform: rotate(360deg); } } + +.clickable-area, +.leaflet-interactive { + cursor: pointer !important; +} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 10927ac3..2d4920f0 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -13,8 +13,7 @@ import { getSpeedColor } from "../maps/polylines"; -import { fetchAndDrawAreas } from "../maps/areas"; -import { handleAreaCreated } from "../maps/areas"; +import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers"; @@ -67,7 +66,7 @@ export default class extends Controller { imperial: this.distanceUnit === 'mi', metric: this.distanceUnit === 'km', maxWidth: 120 - }).addTo(this.map) + }).addTo(this.map); // Add stats control const StatsControl = L.Control.extend({ @@ -107,7 +106,8 @@ export default class extends Controller { // Create a proper Leaflet layer for fog this.fogOverlay = createFogOverlay(); - this.areasLayer = L.layerGroup(); // Initialize areas layer + this.areasLayer = L.layerGroup(); // Initialize areasLayer + this.photoMarkers = L.layerGroup(); this.setupScratchLayer(this.countryCodesMap); @@ -123,7 +123,7 @@ export default class extends Controller { Heatmap: this.heatmapLayer, "Fog of War": new this.fogOverlay(), "Scratch map": this.scratchLayer, - Areas: this.areasLayer, + Areas: this.areasLayer, // Add areasLayer to the control Photos: this.photoMarkers }; @@ -166,6 +166,20 @@ export default class extends Controller { // Fetch and draw areas when the map is loaded fetchAndDrawAreas(this.areasLayer, this.apiKey); + // Add a simple test circle to the map + const testCircle = L.circle([52.514568, 13.350111], { + radius: 100, + color: 'blue', + fillColor: '#30f', + fillOpacity: 0.5, + interactive: true, + zIndexOffset: 1000 + }).addTo(this.map); + + testCircle.on('mouseover', () => { + console.log('Mouse over test circle'); + }); + let fogEnabled = false; // Hide fog by default @@ -248,10 +262,13 @@ export default class extends Controller { } // Store panel state before disconnecting if (this.rightPanel) { - const finalState = document.querySelector('.leaflet-right-panel').style.display !== 'none' ? 'true' : 'false'; + const panel = document.querySelector('.leaflet-right-panel'); + const finalState = panel ? (panel.style.display !== 'none' ? 'true' : 'false') : 'false'; localStorage.setItem('mapPanelOpen', finalState); } - this.map.remove(); + if (this.map) { + this.map.remove(); + } } setupSubscription() { @@ -567,17 +584,26 @@ export default class extends Controller { }, }, }, + edit: { + featureGroup: this.drawnItems + } }); // Handle circle creation - this.map.on(L.Draw.Event.CREATED, (event) => { + this.map.on('draw:created', (event) => { const layer = event.layer; if (event.layerType === 'circle') { - handleAreaCreated(this.areasLayer, layer, this.apiKey); + console.log("Circle created, opening popup..."); // Add debug log + try { + // Add the layer to the map first + layer.addTo(this.map); + handleAreaCreated(this.areasLayer, layer, this.apiKey); + } catch (error) { + console.error("Error in handleAreaCreated:", error); + console.error(error.stack); // Add stack trace + } } - - this.drawnItems.addLayer(layer); }); } diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index 10402c13..e242791f 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -1,54 +1,105 @@ export function handleAreaCreated(areasLayer, layer, apiKey) { + console.log('handleAreaCreated called with apiKey:', apiKey); const radius = layer.getRadius(); const center = layer.getLatLng(); const formHtml = ` -
+

New Area

-
+
- +
-
- +
+ +
`; - layer.bindPopup( - formHtml, { - maxWidth: "auto", - minWidth: 300 - } - ).openPopup(); + console.log('Binding popup to layer'); + layer.bindPopup(formHtml, { + maxWidth: "auto", + minWidth: 300, + closeButton: true, + closeOnClick: false, + className: 'area-form-popup' + }).openPopup(); - layer.on('popupopen', () => { - const form = document.getElementById('circle-form'); - - if (!form) return; - - form.addEventListener('submit', (e) => { - e.preventDefault(); - saveArea(new FormData(form), areasLayer, layer, apiKey); - }); - }); - - // Add the layer to the areas layer group + console.log('Adding layer to areasLayer'); areasLayer.addLayer(layer); + + // Bind the event handler immediately after opening the popup + setTimeout(() => { + console.log('Setting up form handlers'); + const form = document.getElementById('circle-form'); + const saveButton = document.getElementById('save-area-btn'); + const nameInput = document.getElementById('circle-name'); + + console.log('Form:', form); + console.log('Save button:', saveButton); + console.log('Name input:', nameInput); + + if (!form || !saveButton || !nameInput) { + console.error('Required elements not found'); + return; + } + + // Focus the name input + nameInput.focus(); + + // Remove any existing click handlers + const newSaveButton = saveButton.cloneNode(true); + saveButton.parentNode.replaceChild(newSaveButton, saveButton); + + // Add click handler + newSaveButton.addEventListener('click', (e) => { + console.log('Save button clicked'); + e.preventDefault(); + e.stopPropagation(); + + if (!nameInput.value.trim()) { + console.log('Name is empty'); + nameInput.classList.add('input-error'); + return; + } + + console.log('Creating FormData'); + const formData = new FormData(form); + formData.forEach((value, key) => { + console.log(`FormData: ${key} = ${value}`); + }); + + console.log('Calling saveArea'); + saveArea(formData, areasLayer, layer, apiKey); + }); + }, 100); // Small delay to ensure DOM is ready } export function saveArea(formData, areasLayer, layer, apiKey) { + console.log('saveArea called with apiKey:', apiKey); const data = {}; formData.forEach((value, key) => { + console.log('FormData entry:', key, value); const keys = key.split('[').map(k => k.replace(']', '')); if (keys.length > 1) { if (!data[keys[0]]) data[keys[0]] = {}; @@ -58,18 +109,21 @@ export function saveArea(formData, areasLayer, layer, apiKey) { } }); + console.log('Sending fetch request with data:', data); fetch(`/api/v1/areas?api_key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json'}, body: JSON.stringify(data) }) .then(response => { + console.log('Received response:', response); if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }) .then(data => { + console.log('Area saved successfully:', data); layer.closePopup(); layer.bindPopup(` Name: ${data.name}
@@ -79,9 +133,13 @@ export function saveArea(formData, areasLayer, layer, apiKey) { // Add event listener for the delete button layer.on('popupopen', () => { - document.querySelector('.delete-area').addEventListener('click', () => { - deleteArea(data.id, areasLayer, layer, apiKey); - }); + const deleteButton = document.querySelector('.delete-area'); + if (deleteButton) { + deleteButton.addEventListener('click', (e) => { + e.preventDefault(); + deleteArea(data.id, areasLayer, layer, apiKey); + }); + } }); }) .catch(error => { diff --git a/app/services/google_maps/phone_takeout_parser.rb b/app/services/google_maps/phone_takeout_parser.rb index 27b65885..8721f8d5 100644 --- a/app/services/google_maps/phone_takeout_parser.rb +++ b/app/services/google_maps/phone_takeout_parser.rb @@ -144,7 +144,7 @@ class GoogleMaps::PhoneTakeoutParser end def parse_raw_array(raw_data) - raw_data.map do |data_point| + raw_data.flat_map do |data_point| if data_point.dig('visit', 'topCandidate', 'placeLocation') parse_visit_place_location(data_point) elsif data_point.dig('activity', 'start') && data_point.dig('activity', 'end') @@ -152,7 +152,7 @@ class GoogleMaps::PhoneTakeoutParser elsif data_point['timelinePath'] parse_timeline_path(data_point) end - end.flatten.compact + end.compact end def parse_semantic_segments(semantic_segments) diff --git a/app/services/own_tracks/params.rb b/app/services/own_tracks/params.rb index 16ef464d..e5319893 100644 --- a/app/services/own_tracks/params.rb +++ b/app/services/own_tracks/params.rb @@ -16,7 +16,7 @@ class OwnTracks::Params altitude: params[:alt], accuracy: params[:acc], vertical_accuracy: params[:vac], - velocity: params[:vel], + velocity: speed, ssid: params[:SSID], bssid: params[:BSSID], tracker_id: params[:tid], @@ -69,4 +69,16 @@ class OwnTracks::Params else 'unknown' end end + + def speed + return params[:vel] unless owntracks_point? + + # OwnTracks speed is in km/h, so we need to convert it to m/s + # Reference: https://owntracks.org/booklet/tech/json/ + ((params[:vel].to_f * 1000) / 3600).round(1).to_s + end + + def owntracks_point? + params[:topic].present? + end end diff --git a/spec/fixtures/files/owntracks/2024-03.rec b/spec/fixtures/files/owntracks/2024-03.rec index 473591f7..610ffa83 100644 --- a/spec/fixtures/files/owntracks/2024-03.rec +++ b/spec/fixtures/files/owntracks/2024-03.rec @@ -1,5 +1,5 @@ -2024-03-01T09:03:09Z * {"bs":2,"p":100.266,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.332,"vel":0,"t":"p","BSSID":"b0:f2:8:45:94:33","SSID":"Home Wifi","conn":"w","vac":4,"acc":10,"tst":1709283789,"lat":52.225,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true} -2024-03-01T17:46:02Z * {"bs":1,"p":100.28,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.333,"t":"p","vel":0,"BSSID":"b0:f2:8:45:94:33","conn":"w","SSID":"Home Wifi","vac":3,"cog":98,"acc":9,"tst":1709315162,"lat":52.226,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true} +2024-03-01T09:03:09Z * {"bs":2,"p":100.266,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.332,"vel":5,"t":"p","BSSID":"b0:f2:8:45:94:33","SSID":"Home Wifi","conn":"w","vac":4,"acc":10,"tst":1709283789,"lat":52.225,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true} +2024-03-01T17:46:02Z * {"bs":1,"p":100.28,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.333,"t":"p","vel":5,"BSSID":"b0:f2:8:45:94:33","conn":"w","SSID":"Home Wifi","vac":3,"cog":98,"acc":9,"tst":1709315162,"lat":52.226,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true} 2024-03-01T18:26:55Z * {"lon":13.334,"acc":5,"wtst":1696359532,"event":"leave","rid":"5f1d1b","desc":"home","topic":"owntracks/test/iPhone 12 Pro/event","lat":52.227,"t":"c","tst":1709317615,"tid":"RO","_type":"transition","_http":true} 2024-03-01T18:26:55Z * {"cog":40,"batt":85,"lon":13.335,"acc":5,"bs":1,"p":100.279,"vel":3,"vac":3,"lat":52.228,"topic":"owntracks/test/iPhone 12 Pro","t":"c","conn":"m","m":1,"tst":1709317615,"alt":36,"_type":"location","tid":"RO","_http":true} 2024-03-01T18:28:30Z * {"cog":38,"batt":85,"lon":13.336,"acc":5,"bs":1,"p":100.349,"vel":3,"vac":3,"lat":52.229,"topic":"owntracks/test/iPhone 12 Pro","t":"v","conn":"m","m":1,"tst":1709317710,"alt":35,"_type":"location","tid":"RO","_http":true} diff --git a/spec/services/own_tracks/export_parser_spec.rb b/spec/services/own_tracks/export_parser_spec.rb index b358300a..260b81ec 100644 --- a/spec/services/own_tracks/export_parser_spec.rb +++ b/spec/services/own_tracks/export_parser_spec.rb @@ -26,7 +26,7 @@ RSpec.describe OwnTracks::ExportParser do 'altitude' => 36, 'accuracy' => 10, 'vertical_accuracy' => 4, - 'velocity' => '0', + 'velocity' => '1.4', 'connection' => 'wifi', 'ssid' => 'Home Wifi', 'bssid' => 'b0:f2:8:45:94:33', @@ -51,7 +51,7 @@ RSpec.describe OwnTracks::ExportParser do 'tid' => 'RO', 'tst' => 1_709_283_789, 'vac' => 4, - 'vel' => 0, + 'vel' => 5, 'SSID' => 'Home Wifi', 'batt' => 94, 'conn' => 'w', @@ -64,6 +64,12 @@ RSpec.describe OwnTracks::ExportParser do } ) end + + it 'correctly converts speed' do + parser + + expect(Point.first.velocity).to eq('1.4') + end end end end diff --git a/spec/services/own_tracks/params_spec.rb b/spec/services/own_tracks/params_spec.rb index 64f485bf..40605759 100644 --- a/spec/services/own_tracks/params_spec.rb +++ b/spec/services/own_tracks/params_spec.rb @@ -20,7 +20,7 @@ RSpec.describe OwnTracks::Params do altitude: 36, accuracy: 10, vertical_accuracy: 4, - velocity: 0, + velocity: '1.4', ssid: 'Home Wifi', bssid: 'b0:f2:8:45:94:33', tracker_id: 'RO', @@ -39,7 +39,7 @@ RSpec.describe OwnTracks::Params do 'topic' => 'owntracks/test/iPhone 12 Pro', 'alt' => 36, 'lon' => 13.332, - 'vel' => 0, + 'vel' => 5, 't' => 'p', 'BSSID' => 'b0:f2:8:45:94:33', 'SSID' => 'Home Wifi', diff --git a/spec/swagger/api/v1/areas_controller_spec.rb b/spec/swagger/api/v1/areas_controller_spec.rb index 0bd50a31..8a7db2b6 100644 --- a/spec/swagger/api/v1/areas_controller_spec.rb +++ b/spec/swagger/api/v1/areas_controller_spec.rb @@ -16,10 +16,26 @@ describe 'Areas API', type: :request do parameter name: :area, in: :body, schema: { type: :object, properties: { - name: { type: :string }, - latitude: { type: :number }, - longitude: { type: :number }, - radius: { type: :number } + name: { + type: :string, + example: 'Home', + description: 'The name of the area' + }, + latitude: { + type: :number, + example: 40.7128, + description: 'The latitude of the area' + }, + longitude: { + type: :number, + example: -74.0060, + description: 'The longitude of the area' + }, + radius: { + type: :number, + example: 100, + description: 'The radius of the area in meters' + } }, required: %w[name latitude longitude radius] } @@ -47,11 +63,31 @@ describe 'Areas API', type: :request do items: { type: :object, properties: { - id: { type: :integer }, - name: { type: :string }, - latitude: { type: :number }, - longitude: { type: :number }, - radius: { type: :number } + id: { + type: :integer, + example: 1, + description: 'The ID of the area' + }, + name: { + type: :string, + example: 'Home', + description: 'The name of the area' + }, + latitude: { + type: :number, + example: 40.7128, + description: 'The latitude of the area' + }, + longitude: { + type: :number, + example: -74.0060, + description: 'The longitude of the area' + }, + radius: { + type: :number, + example: 100, + description: 'The radius of the area in meters' + } }, required: %w[id name latitude longitude radius] } diff --git a/spec/swagger/api/v1/countries/visited_cities_spec.rb b/spec/swagger/api/v1/countries/visited_cities_spec.rb index 5d199e15..61a7fa43 100644 --- a/spec/swagger/api/v1/countries/visited_cities_spec.rb +++ b/spec/swagger/api/v1/countries/visited_cities_spec.rb @@ -9,7 +9,12 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do description 'Returns a list of visited cities and countries based on tracked points within the specified date range' produces 'application/json' - parameter name: :api_key, in: :query, type: :string, required: true + parameter name: :api_key, + in: :query, + type: :string, + required: true, + example: 'a1b2c3d4e5f6g7h8i9j0', + description: 'Your API authentication key' parameter name: :start_at, in: :query, type: :string, @@ -32,6 +37,36 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do data: { type: :array, description: 'Array of countries and their visited cities', + example: [ + { + country: 'Germany', + cities: [ + { + city: 'Berlin', + points: 4394, + timestamp: 1_724_868_369, + stayed_for: 24_490 + }, + { + city: 'Munich', + points: 2156, + timestamp: 1_724_782_369, + stayed_for: 12_450 + } + ] + }, + { + country: 'France', + cities: [ + { + city: 'Paris', + points: 3267, + timestamp: 1_724_695_969, + stayed_for: 18_720 + } + ] + } + ], items: { type: :object, properties: { diff --git a/spec/swagger/api/v1/health_controller_spec.rb b/spec/swagger/api/v1/health_controller_spec.rb index 63ddf514..7305521f 100644 --- a/spec/swagger/api/v1/health_controller_spec.rb +++ b/spec/swagger/api/v1/health_controller_spec.rb @@ -8,6 +8,22 @@ describe 'Health API', type: :request do tags 'Health' produces 'application/json' response '200', 'Healthy' do + schema type: :object, + properties: { + status: { type: :string } + } + + header 'X-Dawarich-Response', + type: :string, + required: true, + example: 'Hey, I\'m alive!', + description: "Depending on the authentication status of the request, the response will be different. If the request is authenticated, the response will be 'Hey, I'm alive and authenticated!'. If the request is not authenticated, the response will be 'Hey, I'm alive!'." + header 'X-Dawarich-Version', + type: :string, + required: true, + example: '1.0.0', + description: 'The version of the application, for example: 1.0.0' + run_test! end end diff --git a/spec/swagger/api/v1/overland/batches_controller_spec.rb b/spec/swagger/api/v1/overland/batches_controller_spec.rb index 5fce90f1..4ba2e0d3 100644 --- a/spec/swagger/api/v1/overland/batches_controller_spec.rb +++ b/spec/swagger/api/v1/overland/batches_controller_spec.rb @@ -26,7 +26,8 @@ describe 'Overland Batches API', type: :request do deferred: 0, significant_change: 'unknown', locations_in_payload: 1, - device_id: 'Swagger', + device_id: 'iOS device #166', + unique_id: '1234567890', wifi: 'unknown', battery_state: 'unknown', battery_level: 0 @@ -39,36 +40,100 @@ describe 'Overland Batches API', type: :request do parameter name: :locations, in: :body, schema: { type: :object, properties: { - type: { type: :string }, + type: { type: :string, example: 'Feature' }, geometry: { type: :object, properties: { - type: { type: :string }, - coordinates: { type: :array } + type: { type: :string, example: 'Point' }, + coordinates: { type: :array, example: [13.356718, 52.502397] } } }, properties: { type: :object, properties: { - timestamp: { type: :string }, - altitude: { type: :number }, - speed: { type: :number }, - horizontal_accuracy: { type: :number }, - vertical_accuracy: { type: :number }, - motion: { type: :array }, - pauses: { type: :boolean }, - activity: { type: :string }, - desired_accuracy: { type: :number }, - deferred: { type: :number }, - significant_change: { type: :string }, - locations_in_payload: { type: :number }, - device_id: { type: :string }, - wifi: { type: :string }, - battery_state: { type: :string }, - battery_level: { type: :number } - } - }, - required: %w[geometry properties] + timestamp: { + type: :string, + example: '2021-06-01T12:00:00Z', + description: 'Timestamp in ISO 8601 format' + }, + altitude: { + type: :number, + example: 0, + description: 'Altitude in meters' + }, + speed: { + type: :number, + example: 0, + description: 'Speed in meters per second' + }, + horizontal_accuracy: { + type: :number, + example: 0, + description: 'Horizontal accuracy in meters' + }, + vertical_accuracy: { + type: :number, + example: 0, + description: 'Vertical accuracy in meters' + }, + motion: { + type: :array, + example: %w[walking running driving cycling stationary], + description: 'Motion type, for example: automotive_navigation, fitness, other_navigation or other' + }, + activity: { + type: :string, + example: 'unknown', + description: 'Activity type, for example: automotive_navigation, fitness, other_navigation or other' + }, + desired_accuracy: { + type: :number, + example: 0, + description: 'Desired accuracy in meters' + }, + deferred: { + type: :number, + example: 0, + description: 'the distance in meters to defer location updates' + }, + significant_change: { + type: :string, + example: 'disabled', + description: 'a significant change mode, disabled, enabled or exclusive' + }, + locations_in_payload: { + type: :number, + example: 1, + description: 'the number of locations in the payload' + }, + device_id: { + type: :string, + example: 'iOS device #166', + description: 'the device id' + }, + unique_id: { + type: :string, + example: '1234567890', + description: 'the device\'s Unique ID as set by Apple' + }, + wifi: { + type: :string, + example: 'unknown', + description: 'the WiFi network name' + }, + battery_state: { + type: :string, + example: 'unknown', + description: 'the battery state, unknown, unplugged, charging or full' + }, + battery_level: { + type: :number, + example: 0, + description: 'the battery level percentage, from 0 to 1' + } + }, + required: %w[geometry properties] + } } } diff --git a/spec/swagger/api/v1/owntracks/points_controller_spec.rb b/spec/swagger/api/v1/owntracks/points_controller_spec.rb index 8476b514..00157df8 100644 --- a/spec/swagger/api/v1/owntracks/points_controller_spec.rb +++ b/spec/swagger/api/v1/owntracks/points_controller_spec.rb @@ -39,29 +39,29 @@ describe 'OwnTracks Points API', type: :request do parameter name: :point, in: :body, schema: { type: :object, properties: { - batt: { type: :number }, - lon: { type: :number }, - acc: { type: :number }, - bs: { type: :number }, - inrids: { type: :array }, - BSSID: { type: :string }, - SSID: { type: :string }, - vac: { type: :number }, - inregions: { type: :array }, - lat: { type: :number }, - topic: { type: :string }, - t: { type: :string }, - conn: { type: :string }, - m: { type: :number }, - tst: { type: :number }, - alt: { type: :number }, - _type: { type: :string }, - tid: { type: :string }, - _http: { type: :boolean }, - ghash: { type: :string }, - isorcv: { type: :string }, - isotst: { type: :string }, - disptst: { type: :string } + batt: { type: :number, description: 'Device battery level (percentage)' }, + lon: { type: :number, description: 'Longitude coordinate' }, + acc: { type: :number, description: 'Accuracy of position in meters' }, + bs: { type: :number, description: 'Battery status (0=unknown, 1=unplugged, 2=charging, 3=full)' }, + inrids: { type: :array, description: 'Array of region IDs device is currently in' }, + BSSID: { type: :string, description: 'Connected WiFi access point MAC address' }, + SSID: { type: :string, description: 'Connected WiFi network name' }, + vac: { type: :number, description: 'Vertical accuracy in meters' }, + inregions: { type: :array, description: 'Array of region names device is currently in' }, + lat: { type: :number, description: 'Latitude coordinate' }, + topic: { type: :string, description: 'MQTT topic in format owntracks/user/device' }, + t: { type: :string, description: 'Type of message (p=position, c=circle, etc)' }, + conn: { type: :string, description: 'Connection type (w=wifi, m=mobile, o=offline)' }, + m: { type: :number, description: 'Motion state (0=stopped, 1=moving)' }, + tst: { type: :number, description: 'Timestamp in Unix epoch time' }, + alt: { type: :number, description: 'Altitude in meters' }, + _type: { type: :string, description: 'Internal message type (usually "location")' }, + tid: { type: :string, description: 'Tracker ID used to display the initials of a user' }, + _http: { type: :boolean, description: 'Whether message was sent via HTTP (true) or MQTT (false)' }, + ghash: { type: :string, description: 'Geohash of location' }, + isorcv: { type: :string, description: 'ISO 8601 timestamp when message was received' }, + isotst: { type: :string, description: 'ISO 8601 timestamp of the location fix' }, + disptst: { type: :string, description: 'Human-readable timestamp of the location fix' } }, required: %w[owntracks/jane] } diff --git a/spec/swagger/api/v1/points_controller_spec.rb b/spec/swagger/api/v1/points_controller_spec.rb index d3dc087c..e5b8bf01 100644 --- a/spec/swagger/api/v1/points_controller_spec.rb +++ b/spec/swagger/api/v1/points_controller_spec.rb @@ -101,27 +101,73 @@ describe 'Points API', type: :request do geometry: { type: :object, properties: { - type: { type: :string }, - coordinates: { type: :array, items: { type: :number } } + type: { + type: :string, + example: 'Point', + description: 'the geometry type, always Point' + }, + coordinates: { + type: :array, + items: { + type: :number, + example: [-122.40530871, 37.74430413], + description: 'the coordinates of the point, longitude and latitude' + } + } } }, properties: { type: :object, properties: { - timestamp: { type: :string }, - horizontal_accuracy: { type: :number }, - vertical_accuracy: { type: :number }, - altitude: { type: :number }, - speed: { type: :number }, - speed_accuracy: { type: :number }, - course: { type: :number }, - course_accuracy: { type: :number }, - track_id: { type: :string }, - device_id: { type: :string } + timestamp: { + type: :string, + example: '2025-01-17T21:03:01Z', + description: 'the timestamp of the point' + }, + horizontal_accuracy: { + type: :number, + example: 5, + description: 'the horizontal accuracy of the point in meters' + }, + vertical_accuracy: { + type: :number, + example: -1, + description: 'the vertical accuracy of the point in meters' + }, + altitude: { + type: :number, + example: 0, + description: 'the altitude of the point in meters' + }, + speed: { + type: :number, + example: 92.088, + description: 'the speed of the point in meters per second' + }, + speed_accuracy: { + type: :number, + example: 0, + description: 'the speed accuracy of the point in meters per second' + }, + course_accuracy: { + type: :number, + example: 0, + description: 'the course accuracy of the point in degrees' + }, + track_id: { + type: :string, + example: '799F32F5-89BB-45FB-A639-098B1B95B09F', + description: 'the track id of the point set by the device' + }, + device_id: { + type: :string, + example: '8D5D4197-245B-4619-A88B-2049100ADE46', + description: 'the device id of the point set by the device' + } } - } - }, - required: %w[geometry properties] + }, + required: %w[geometry properties] + } } parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key' diff --git a/spec/swagger/api/v1/settings_controller_spec.rb b/spec/swagger/api/v1/settings_controller_spec.rb index 523ca449..aecba56b 100644 --- a/spec/swagger/api/v1/settings_controller_spec.rb +++ b/spec/swagger/api/v1/settings_controller_spec.rb @@ -20,12 +20,26 @@ describe 'Settings API', type: :request do parameter name: :settings, in: :body, schema: { type: :object, properties: { - route_opacity: { type: :number }, - meters_between_routes: { type: :number }, - minutes_between_routes: { type: :number }, - fog_of_war_meters: { type: :number }, - time_threshold_minutes: { type: :number }, - merge_threshold_minutes: { type: :number } + route_opacity: { + type: :number, + example: 0.3, + description: 'the opacity of the route, float between 0 and 1' + }, + meters_between_routes: { + type: :number, + example: 100, + description: 'the distance between routes in meters' + }, + minutes_between_routes: { + type: :number, + example: 100, + description: 'the time between routes in minutes' + }, + fog_of_war_meters: { + type: :number, + example: 100, + description: 'the fog of war distance in meters' + } }, optional: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters time_threshold_minutes merge_threshold_minutes] @@ -49,12 +63,26 @@ describe 'Settings API', type: :request do settings: { type: :object, properties: { - route_opacity: { type: :string }, - meters_between_routes: { type: :string }, - minutes_between_routes: { type: :string }, - fog_of_war_meters: { type: :string }, - time_threshold_minutes: { type: :string }, - merge_threshold_minutes: { type: :string } + route_opacity: { + type: :string, + example: 0.3, + description: 'the opacity of the route, float between 0 and 1' + }, + meters_between_routes: { + type: :string, + example: 100, + description: 'the distance between routes in meters' + }, + minutes_between_routes: { + type: :string, + example: 100, + description: 'the time between routes in minutes' + }, + fog_of_war_meters: { + type: :string, + example: 100, + description: 'the fog of war distance in meters' + } }, required: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters time_threshold_minutes merge_threshold_minutes] diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 7264b64e..3ce30e09 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -29,12 +29,20 @@ paths: properties: name: type: string + example: Home + description: The name of the area latitude: type: number + example: 40.7128 + description: The latitude of the area longitude: type: number + example: -74.006 + description: The longitude of the area radius: type: number + example: 100 + description: The radius of the area in meters required: - name - latitude @@ -71,14 +79,24 @@ paths: properties: id: type: integer + example: 1 + description: The ID of the area name: type: string + example: Home + description: The name of the area latitude: type: number + example: 40.7128 + description: The latitude of the area longitude: type: number + example: -74.006 + description: The longitude of the area radius: type: number + example: 100 + description: The radius of the area in meters required: - id - name @@ -117,6 +135,8 @@ paths: - name: api_key in: query required: true + example: a1b2c3d4e5f6g7h8i9j0 + description: Your API authentication key schema: type: string - name: start_at @@ -146,6 +166,23 @@ paths: data: type: array description: Array of countries and their visited cities + example: + - country: Germany + cities: + - city: Berlin + points: 4394 + timestamp: 1724868369 + stayed_for: 24490 + - city: Munich + points: 2156 + timestamp: 1724782369 + stayed_for: 12450 + - country: France + cities: + - city: Paris + points: 3267 + timestamp: 1724695969 + stayed_for: 18720 items: type: object properties: @@ -192,6 +229,27 @@ paths: responses: '200': description: Healthy + headers: + X-Dawarich-Response: + type: string + required: true + example: Hey, I'm alive! + description: Depending on the authentication status of the request, + the response will be different. If the request is authenticated, the + response will be 'Hey, I'm alive and authenticated!'. If the request + is not authenticated, the response will be 'Hey, I'm alive!'. + X-Dawarich-Version: + type: string + required: true + example: 1.0.0 + description: 'The version of the application, for example: 1.0.0' + content: + application/json: + schema: + type: object + properties: + status: + type: string "/api/v1/overland/batches": post: summary: Creates a batch of points @@ -217,51 +275,97 @@ paths: properties: type: type: string + example: Feature geometry: type: object properties: type: type: string + example: Point coordinates: type: array + example: + - 13.356718 + - 52.502397 properties: type: object properties: timestamp: type: string + example: '2021-06-01T12:00:00Z' + description: Timestamp in ISO 8601 format altitude: type: number + example: 0 + description: Altitude in meters speed: type: number + example: 0 + description: Speed in meters per second horizontal_accuracy: type: number + example: 0 + description: Horizontal accuracy in meters vertical_accuracy: type: number + example: 0 + description: Vertical accuracy in meters motion: type: array - pauses: - type: boolean + example: + - walking + - running + - driving + - cycling + - stationary + description: 'Motion type, for example: automotive_navigation, + fitness, other_navigation or other' activity: type: string + example: unknown + description: 'Activity type, for example: automotive_navigation, + fitness, other_navigation or other' desired_accuracy: type: number + example: 0 + description: Desired accuracy in meters deferred: type: number + example: 0 + description: the distance in meters to defer location updates significant_change: type: string + example: disabled + description: a significant change mode, disabled, enabled or + exclusive locations_in_payload: type: number + example: 1 + description: the number of locations in the payload device_id: type: string + example: 'iOS device #166' + description: the device id + unique_id: + type: string + example: '1234567890' + description: the device's Unique ID as set by Apple wifi: type: string + example: unknown + description: the WiFi network name battery_state: type: string + example: unknown + description: the battery state, unknown, unplugged, charging + or full battery_level: type: number - required: - - geometry - - properties + example: 0 + description: the battery level percentage, from 0 to 1 + required: + - geometry + - properties examples: '0': summary: Creates a batch of points @@ -286,7 +390,8 @@ paths: deferred: 0 significant_change: unknown locations_in_payload: 1 - device_id: Swagger + device_id: 'iOS device #166' + unique_id: '1234567890' wifi: unknown battery_state: unknown battery_level: 0 @@ -315,50 +420,74 @@ paths: properties: batt: type: number + description: Device battery level (percentage) lon: type: number + description: Longitude coordinate acc: type: number + description: Accuracy of position in meters bs: type: number + description: Battery status (0=unknown, 1=unplugged, 2=charging, + 3=full) inrids: type: array + description: Array of region IDs device is currently in BSSID: type: string + description: Connected WiFi access point MAC address SSID: type: string + description: Connected WiFi network name vac: type: number + description: Vertical accuracy in meters inregions: type: array + description: Array of region names device is currently in lat: type: number + description: Latitude coordinate topic: type: string + description: MQTT topic in format owntracks/user/device t: type: string + description: Type of message (p=position, c=circle, etc) conn: type: string + description: Connection type (w=wifi, m=mobile, o=offline) m: type: number + description: Motion state (0=stopped, 1=moving) tst: type: number + description: Timestamp in Unix epoch time alt: type: number + description: Altitude in meters _type: type: string + description: Internal message type (usually "location") tid: type: string + description: Tracker ID used to display the initials of a user _http: type: boolean + description: Whether message was sent via HTTP (true) or MQTT (false) ghash: type: string + description: Geohash of location isorcv: type: string + description: ISO 8601 timestamp when message was received isotst: type: string + description: ISO 8601 timestamp of the location fix disptst: type: string + description: Human-readable timestamp of the location fix required: - owntracks/jane examples: @@ -725,36 +854,58 @@ paths: properties: type: type: string + example: Point + description: the geometry type, always Point coordinates: type: array items: type: number + example: + - -122.40530871 + - 37.74430413 + description: the coordinates of the point, longitude and latitude properties: type: object properties: timestamp: type: string + example: '2025-01-17T21:03:01Z' + description: the timestamp of the point horizontal_accuracy: type: number + example: 5 + description: the horizontal accuracy of the point in meters vertical_accuracy: type: number + example: -1 + description: the vertical accuracy of the point in meters altitude: type: number + example: 0 + description: the altitude of the point in meters speed: type: number + example: 92.088 + description: the speed of the point in meters per second speed_accuracy: type: number - course: - type: number + example: 0 + description: the speed accuracy of the point in meters per second course_accuracy: type: number + example: 0 + description: the course accuracy of the point in degrees track_id: type: string + example: 799F32F5-89BB-45FB-A639-098B1B95B09F + description: the track id of the point set by the device device_id: type: string - required: - - geometry - - properties + example: 8D5D4197-245B-4619-A88B-2049100ADE46 + description: the device id of the point set by the device + required: + - geometry + - properties examples: '0': summary: Creates a batch of points @@ -821,16 +972,20 @@ paths: properties: route_opacity: type: number + example: 0.3 + description: the opacity of the route, float between 0 and 1 meters_between_routes: type: number + example: 100 + description: the distance between routes in meters minutes_between_routes: type: number + example: 100 + description: the time between routes in minutes fog_of_war_meters: type: number - time_threshold_minutes: - type: number - merge_threshold_minutes: - type: number + example: 100 + description: the fog of war distance in meters optional: - route_opacity - meters_between_routes @@ -873,16 +1028,21 @@ paths: properties: route_opacity: type: string + example: 0.3 + description: the opacity of the route, float between 0 and + 1 meters_between_routes: type: string + example: 100 + description: the distance between routes in meters minutes_between_routes: type: string + example: 100 + description: the time between routes in minutes fog_of_war_meters: type: string - time_threshold_minutes: - type: string - merge_threshold_minutes: - type: string + example: 100 + description: the fog of war distance in meters required: - route_opacity - meters_between_routes From dd48ef4177e714d33ebcc05fb670c6960b6c20b2 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 7 Feb 2025 21:08:31 +0100 Subject: [PATCH 123/157] Implement area clicks --- app/javascript/controllers/maps_controller.js | 101 +++++++++++++++--- app/javascript/maps/areas.js | 100 +++++++++++++---- 2 files changed, 164 insertions(+), 37 deletions(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 2d4920f0..6053fee6 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -106,7 +106,92 @@ export default class extends Controller { // Create a proper Leaflet layer for fog this.fogOverlay = createFogOverlay(); - this.areasLayer = L.layerGroup(); // Initialize areasLayer + // Create custom pane for areas + this.map.createPane('areasPane'); + this.map.getPane('areasPane').style.zIndex = 650; + this.map.getPane('areasPane').style.pointerEvents = 'all'; + + // Initialize areasLayer as a feature group and add it to the map immediately + this.areasLayer = new L.FeatureGroup().addTo(this.map); + + // Create a custom pane for the test circle + this.map.createPane('testCirclePane'); + this.map.getPane('testCirclePane').style.zIndex = 650; // Above most layers + this.map.getPane('testCirclePane').style.pointerEvents = 'all'; // Ensure events are received + + // Add a simple test circle directly to the map + const testCircle = L.circle([52.514568, 13.350111], { + radius: 500, + color: 'blue', + fillColor: '#30f', + fillOpacity: 0.8, + weight: 3, + interactive: true, + bubblingMouseEvents: false, + pane: 'testCirclePane' + }).addTo(this.map); + + // Add a popup to the test circle + testCircle.bindPopup("Test Circle - Berlin"); + + // Pan to the test circle location + this.map.setView([52.514568, 13.350111], 14); + + // Store reference to test circle + this.testCircle = testCircle; + + // Wait for the circle to be added to the DOM + setTimeout(() => { + // Get the circle's SVG path element + const circlePath = testCircle.getElement(); + if (circlePath) { + console.log('Found circle element:', circlePath); + + // Add CSS styles + circlePath.style.cursor = 'pointer'; + circlePath.style.transition = 'all 0.3s ease'; + + // Add direct DOM event listeners + circlePath.addEventListener('click', (e) => { + console.log('Direct click on circle!'); + e.stopPropagation(); + testCircle.openPopup(); + }); + + circlePath.addEventListener('mouseenter', (e) => { + console.log('Direct mouseenter on circle!'); + e.stopPropagation(); + testCircle.setStyle({ + fillOpacity: 1, + weight: 4 + }); + }); + + circlePath.addEventListener('mouseleave', (e) => { + console.log('Direct mouseleave on circle!'); + e.stopPropagation(); + testCircle.setStyle({ + fillOpacity: 0.8, + weight: 3 + }); + }); + } else { + console.log('Could not find circle element'); + } + }, 1000); + + // Add debug click handler to map + this.map.on('click', (e) => { + console.log('Map clicked at:', e.latlng); + // Log all layers at click point + const point = this.map.latLngToContainerPoint(e.latlng); + const size = this.map.getSize(); + console.log('Visible layers:', this.map._layers); + console.log('Map size:', size); + console.log('Click point:', point); + console.log('Test circle visible:', this.map.hasLayer(testCircle)); + console.log('Test circle bounds:', testCircle.getBounds()); + }); this.photoMarkers = L.layerGroup(); @@ -166,20 +251,6 @@ export default class extends Controller { // Fetch and draw areas when the map is loaded fetchAndDrawAreas(this.areasLayer, this.apiKey); - // Add a simple test circle to the map - const testCircle = L.circle([52.514568, 13.350111], { - radius: 100, - color: 'blue', - fillColor: '#30f', - fillOpacity: 0.5, - interactive: true, - zIndexOffset: 1000 - }).addTo(this.map); - - testCircle.on('mouseover', () => { - console.log('Mouse over test circle'); - }); - let fogEnabled = false; // Hide fog by default diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index e242791f..d5a58586 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -169,6 +169,7 @@ export function deleteArea(id, areasLayer, layer, apiKey) { } export function fetchAndDrawAreas(areasLayer, apiKey) { + console.log('Fetching areas...'); fetch(`/api/v1/areas?api_key=${apiKey}`, { method: 'GET', headers: { @@ -182,35 +183,90 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { return response.json(); }) .then(data => { + console.log('Received areas:', data); + + // Clear existing areas + areasLayer.clearLayers(); + data.forEach(area => { - // Check if necessary fields are present if (area.latitude && area.longitude && area.radius && area.name && area.id) { - const layer = L.circle([area.latitude, area.longitude], { - radius: area.radius, + console.log('Creating circle for area:', area); + + // Convert string coordinates to numbers + const lat = parseFloat(area.latitude); + const lng = parseFloat(area.longitude); + const radius = parseFloat(area.radius); + + // Create circle with custom pane + const circle = L.circle([lat, lng], { + radius: radius, color: 'red', fillColor: '#f03', - fillOpacity: 0.5 - }).bindPopup(` - Name: ${area.name}
- Radius: ${Math.round(area.radius)} meters
- [Delete] - `); - - areasLayer.addLayer(layer); // Add to areas layer group - - // Add event listener for the delete button - layer.on('popupopen', () => { - document.querySelector('.delete-area').addEventListener('click', (e) => { - e.preventDefault(); - if (confirm('Are you sure you want to delete this area?')) { - deleteArea(area.id, areasLayer, layer, apiKey); - } - }); + fillOpacity: 0.5, + weight: 2, + interactive: true, + bubblingMouseEvents: false, + pane: 'areasPane' }); - } else { - console.error('Area missing required fields:', area); + + // Bind popup content + const popupContent = ` +
+
+

${area.name}

+

Radius: ${Math.round(radius)} meters

+

Center: [${lat.toFixed(4)}, ${lng.toFixed(4)}]

+
+ +
+
+
+ `; + circle.bindPopup(popupContent); + + // Add to layer group + areasLayer.addLayer(circle); + + // Wait for the circle to be added to the DOM + setTimeout(() => { + const circlePath = circle.getElement(); + if (circlePath) { + // Add CSS styles + circlePath.style.cursor = 'pointer'; + circlePath.style.transition = 'all 0.3s ease'; + + // Add direct DOM event listeners + circlePath.addEventListener('click', (e) => { + console.log('Area circle clicked:', area.name); + e.stopPropagation(); + circle.openPopup(); + }); + + circlePath.addEventListener('mouseenter', (e) => { + console.log('Mouse entered area:', area.name); + e.stopPropagation(); + circle.setStyle({ + fillOpacity: 0.8, + weight: 3 + }); + }); + + circlePath.addEventListener('mouseleave', (e) => { + console.log('Mouse left area:', area.name); + e.stopPropagation(); + circle.setStyle({ + fillOpacity: 0.5, + weight: 2 + }); + }); + } + }, 100); + + console.log('Adding circle to areasLayer'); } }); + + console.log('All circles added to areasLayer'); }) .catch(error => { console.error('There was a problem with the fetch request:', error); From ffee59b7c3be06976881c1857c532078288c1ec5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 7 Feb 2025 21:17:38 +0100 Subject: [PATCH 124/157] Fix deleting an area --- app/javascript/maps/areas.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index d5a58586..4312b733 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -1,3 +1,5 @@ +import { showFlashMessage } from "./helpers"; + export function handleAreaCreated(areasLayer, layer, apiKey) { console.log('handleAreaCreated called with apiKey:', apiKey); const radius = layer.getRadius(); @@ -162,6 +164,8 @@ export function deleteArea(id, areasLayer, layer, apiKey) { }) .then(data => { areasLayer.removeLayer(layer); // Remove the layer from the areas layer group + + showFlashMessage('notice', `Area was successfully deleted!`); }) .catch(error => { console.error('There was a problem with the delete request:', error); @@ -224,6 +228,20 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { `; circle.bindPopup(popupContent); + // Add delete button handler when popup opens + circle.on('popupopen', () => { + const deleteButton = document.querySelector('.delete-area[data-id="' + area.id + '"]'); + if (deleteButton) { + deleteButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (confirm('Are you sure you want to delete this area?')) { + deleteArea(area.id, areasLayer, circle, apiKey); + } + }); + } + }); + // Add to layer group areasLayer.addLayer(circle); From 51724db87450f77463fe9dc9b675f3c5813670a4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 7 Feb 2025 21:19:34 +0100 Subject: [PATCH 125/157] Remove test circle --- app/javascript/controllers/maps_controller.js | 80 ------------------- app/javascript/maps/areas.js | 11 --- 2 files changed, 91 deletions(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 6053fee6..c8729b6e 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -113,86 +113,6 @@ export default class extends Controller { // Initialize areasLayer as a feature group and add it to the map immediately this.areasLayer = new L.FeatureGroup().addTo(this.map); - - // Create a custom pane for the test circle - this.map.createPane('testCirclePane'); - this.map.getPane('testCirclePane').style.zIndex = 650; // Above most layers - this.map.getPane('testCirclePane').style.pointerEvents = 'all'; // Ensure events are received - - // Add a simple test circle directly to the map - const testCircle = L.circle([52.514568, 13.350111], { - radius: 500, - color: 'blue', - fillColor: '#30f', - fillOpacity: 0.8, - weight: 3, - interactive: true, - bubblingMouseEvents: false, - pane: 'testCirclePane' - }).addTo(this.map); - - // Add a popup to the test circle - testCircle.bindPopup("Test Circle - Berlin"); - - // Pan to the test circle location - this.map.setView([52.514568, 13.350111], 14); - - // Store reference to test circle - this.testCircle = testCircle; - - // Wait for the circle to be added to the DOM - setTimeout(() => { - // Get the circle's SVG path element - const circlePath = testCircle.getElement(); - if (circlePath) { - console.log('Found circle element:', circlePath); - - // Add CSS styles - circlePath.style.cursor = 'pointer'; - circlePath.style.transition = 'all 0.3s ease'; - - // Add direct DOM event listeners - circlePath.addEventListener('click', (e) => { - console.log('Direct click on circle!'); - e.stopPropagation(); - testCircle.openPopup(); - }); - - circlePath.addEventListener('mouseenter', (e) => { - console.log('Direct mouseenter on circle!'); - e.stopPropagation(); - testCircle.setStyle({ - fillOpacity: 1, - weight: 4 - }); - }); - - circlePath.addEventListener('mouseleave', (e) => { - console.log('Direct mouseleave on circle!'); - e.stopPropagation(); - testCircle.setStyle({ - fillOpacity: 0.8, - weight: 3 - }); - }); - } else { - console.log('Could not find circle element'); - } - }, 1000); - - // Add debug click handler to map - this.map.on('click', (e) => { - console.log('Map clicked at:', e.latlng); - // Log all layers at click point - const point = this.map.latLngToContainerPoint(e.latlng); - const size = this.map.getSize(); - console.log('Visible layers:', this.map._layers); - console.log('Map size:', size); - console.log('Click point:', point); - console.log('Test circle visible:', this.map.hasLayer(testCircle)); - console.log('Test circle bounds:', testCircle.getBounds()); - }); - this.photoMarkers = L.layerGroup(); this.setupScratchLayer(this.countryCodesMap); diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index 4312b733..f3f5f4f7 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -187,15 +187,11 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { return response.json(); }) .then(data => { - console.log('Received areas:', data); - // Clear existing areas areasLayer.clearLayers(); data.forEach(area => { if (area.latitude && area.longitude && area.radius && area.name && area.id) { - console.log('Creating circle for area:', area); - // Convert string coordinates to numbers const lat = parseFloat(area.latitude); const lng = parseFloat(area.longitude); @@ -255,13 +251,11 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { // Add direct DOM event listeners circlePath.addEventListener('click', (e) => { - console.log('Area circle clicked:', area.name); e.stopPropagation(); circle.openPopup(); }); circlePath.addEventListener('mouseenter', (e) => { - console.log('Mouse entered area:', area.name); e.stopPropagation(); circle.setStyle({ fillOpacity: 0.8, @@ -270,7 +264,6 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { }); circlePath.addEventListener('mouseleave', (e) => { - console.log('Mouse left area:', area.name); e.stopPropagation(); circle.setStyle({ fillOpacity: 0.5, @@ -279,12 +272,8 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { }); } }, 100); - - console.log('Adding circle to areasLayer'); } }); - - console.log('All circles added to areasLayer'); }) .catch(error => { console.error('There was a problem with the fetch request:', error); From 147e0017c594bb11c60486f0e15e3cca18f85232 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 7 Feb 2025 23:05:47 +0100 Subject: [PATCH 126/157] Update postgres image --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 93c0296f..ee80f3fc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,7 +17,7 @@ services: start_period: 30s timeout: 10s dawarich_db: - image: postgres:14.2-alpine + image: postgis/postgis:14-3.5-alpine shm_size: 1G container_name: dawarich_db volumes: From 3a555065d330346126dee5ba13f688acbdc72858 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 12:08:01 +0100 Subject: [PATCH 127/157] Make popups a bit nicer --- app/javascript/controllers/maps_controller.js | 3 +-- app/javascript/maps/areas.js | 11 ++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index c8729b6e..123dcc0e 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -112,7 +112,7 @@ export default class extends Controller { this.map.getPane('areasPane').style.pointerEvents = 'all'; // Initialize areasLayer as a feature group and add it to the map immediately - this.areasLayer = new L.FeatureGroup().addTo(this.map); + this.areasLayer = new L.FeatureGroup(); this.photoMarkers = L.layerGroup(); this.setupScratchLayer(this.countryCodesMap); @@ -585,7 +585,6 @@ export default class extends Controller { const layer = event.layer; if (event.layerType === 'circle') { - console.log("Circle created, opening popup..."); // Add debug log try { // Add the layer to the map first layer.addTo(this.map); diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index f3f5f4f7..a7179297 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -6,14 +6,11 @@ export function handleAreaCreated(areasLayer, layer, apiKey) { const center = layer.getLatLng(); const formHtml = ` -
+

New Area

-
@@ -211,13 +208,13 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { // Bind popup content const popupContent = ` -
+

${area.name}

Radius: ${Math.round(radius)} meters

Center: [${lat.toFixed(4)}, ${lng.toFixed(4)}]

- +
From 9c8d0649b297add3d7865e9c7bfc52b07d9517bf Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 22:16:29 +0100 Subject: [PATCH 128/157] Update CHANGELOG.md to fix speed conversion --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f2d5a6..60b23cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Now, we need to fix existing points with speed in kilometers per hour. The follo To convert speed in kilometers per hour to meters per second in your points, follow these steps: 1. Enter [Dawarich console](https://dawarich.app/docs/FAQ#how-to-enter-dawarich-console) -2. Run `points = Point.where(import_id: nil).where("velocity != ? OR velocity != ?", nil, "0")`. This will return all tracked (not imported) points. +2. Run `points = Point.where(import_id: nil).where.not(velocity: [nil, "0"]).where("velocity NOT LIKE '%.%'")`. This will return all tracked (not imported) points. 3. Run ```ruby points.update_all("velocity = CAST(ROUND(CAST((CAST(velocity AS FLOAT) * 1000 / 3600) AS NUMERIC), 1) AS TEXT)") @@ -29,10 +29,10 @@ points.update_all("velocity = CAST(ROUND(CAST((CAST(velocity AS FLOAT) * 1000 / This will convert speed in kilometers per hour to meters per second and round it to 1 decimal place. -If you have been using both speed units, but you know the dates where you were tracking with speed in kilometers per hour, on the second step of the instruction above, you can add `where("timestamp BETWEEN ? AND ?", "2025-01-01", "2025-01-31")` to the query to convert speed in kilometers per hour to meters per second only for a specific period of time. Resulting query will look like this: +If you have been using both speed units, but you know the dates where you were tracking with speed in kilometers per hour, on the second step of the instruction above, you can add `where("timestamp BETWEEN ? AND ?", Date.parse("2025-01-01").beginning_of_day.to_i, Date.parse("2025-01-31").end_of_day.to_i)` to the query to convert speed in kilometers per hour to meters per second only for a specific period of time. Resulting query will look like this: ```ruby -points = Point.where(import_id: nil).where("velocity != ? OR velocity != ?", nil, "0").where("timestamp BETWEEN ? AND ?", "2025-01-01", "2025-01-31") +points = Point.where(import_id: nil).where.not(velocity: [nil, "0"]).where("timestamp BETWEEN ? AND ?", Date.parse("2025-01-01").beginning_of_day.to_i, Date.parse("2025-01-31").end_of_day.to_i).where("velocity NOT LIKE '%.%'") ``` This will select points tracked between January 1st and January 31st 2025. Then just use step 3 to convert speed in kilometers per hour to meters per second. From c1f27e4f2dac40b3c0b8077ad1171d207b39771f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 22:33:29 +0100 Subject: [PATCH 129/157] Update Ruby version to 3.4.1 --- .devcontainer/Dockerfile | 2 +- .github/workflows/ci.yml | 2 +- docker/Dockerfile.prod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a61f0adb..6569b129 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # Base-Image for Ruby and Node.js -FROM ruby:3.3.4-alpine +FROM ruby:3.4.1-alpine ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d85eadd..fb1a5bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.3.4' + ruby-version: '3.4.1' bundler-cache: true - name: Set up Node.js diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 8e801e98..1d383cc7 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -1,4 +1,4 @@ -FROM ruby:3.3.4-alpine +FROM ruby:3.4.1-alpine ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 From 57e721d08cff7c8f2419d3e2fb1b6d134a018397 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 22:49:31 +0100 Subject: [PATCH 130/157] Update app version and changelog --- .app_version | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 379191a4..2094a100 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.7 +0.24.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b23cbc..ec510f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,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.23.7 - 2025-02-06 +# 0.24.0 - 2025-02-09 ## Points speed units From 27242dd4979005d429b86eea0c26fc372f98b5d6 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 22:53:05 +0100 Subject: [PATCH 131/157] Fix changelog --- CHANGELOG.md | 2052 -------------------------------------------------- 1 file changed, 2052 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec510f46..68993055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,3 @@ - # Change Log All notable changes to this project will be documented in this file. @@ -2086,2054 +2085,3 @@ You can now specify the host of the application by setting the `APPLICATION_HOST - Specified gem version in Docker entrypoint ### Fixed - -points.update_all("velocity = CAST(ROUND(CAST((CAST(velocity AS FLOAT) * 1000 / 3600) AS NUMERIC), 1) AS TEXT)") - - -``` - -This will convert speed in kilometers per hour to meters per second. - -### Fixed - -- After deleting one point from the map, other points can now be deleted as well. #723 #678 -- Fixed a bug where export file was not being deleted from the server after it was deleted. #808 -### Added - -- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. #800 - -# 0.23.6 - 2025-02-06 - -### Added - -- Enabled Postgis extension for PostgreSQL. -- Trips are now store their paths in the database independently of the points. -- Trips are now being rendered on the map using their precalculated paths instead of list of coordinates. - -### Changed - -- Ruby version was updated to 3.4.1. -- Requesting photos on the Map page now uses the start and end dates from the URL params. #589 - -# 0.23.5 - 2025-01-22 - -### Added - -- A test for building rc Docker image. - -### Fixed - -- Fix authentication to `GET /api/v1/countries/visited_cities` with header `Authorization: Bearer YOUR_API_KEY` instead of `api_key` query param. #679 -- Fix a bug where a gpx file with empty tracks was not being imported. #646 -- Fix a bug where rc version was being checked as a stable release. #711 - -# 0.23.3 - 2025-01-21 - -### Changed - -- Synology-related files are now up to date. #684 - -### Fixed - -- Drastically improved performance for Google's Records.json import. It will now take less than 5 minutes to import 500,000 points, which previously took a few hours. - -### Fixed - -- Add index only if it doesn't exist. - -# 0.23.1 - 2025-01-21 - -### Fixed - -- Renamed unique index on points to `unique_points_lat_long_timestamp_user_id_index` to fix naming conflict with `unique_points_index`. - -# 0.23.0 - 2025-01-20 - -## ⚠️ IMPORTANT ⚠️ - -This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a [backup](https://dawarich.app/docs/tutorials/backup-and-restore) before updating to this version. - -### Added - -- `POST /api/v1/points/create` endpoint added. -- An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations. -- `GET /api/v1/users/me` endpoint added to get current user. - -# 0.22.4 - 2025-01-20 - -### Added - -- You can now drag-n-drop a point on the map to update its position. Enable the "Points" layer on the map to see the points. -- `PATCH /api/v1/points/:id` endpoint added to update a point. It only accepts `latitude` and `longitude` params. #51 #503 - -### Changed - -- Run seeds even in prod env so Unraid users could have default user. -- Precompile assets in production env using dummy secret key base. - -### Fixed - -- Fixed a bug where route wasn't highlighted when it was hovered or clicked. - -# 0.22.3 - 2025-01-14 - -### Changed - -- The Map now uses a canvas to draw polylines, points and fog of war. This should improve performance in browser with a lot of points and polylines. - -# 0.22.2 - 2025-01-13 - -✨ The Fancy Routes release ✨ - -### Added - -- In the Map Settings (coggle in the top left corner of the map), you can now enable/disable the Fancy Routes feature. Simply said, it will color your routes based on the speed of each segment. -- Hovering over a polyline now shows the speed of the segment. Move cursor over a polyline to see the speed of different segments. -- Distance and points number in the custom control to the map. - -### Changed - -- The name of the "Polylines" feature is now "Routes". - -⚠️ Important note on the Prometheus monitoring ⚠️ - -In the previous release, `bin/dev` command in the default `docker-compose.yml` file was replaced with `bin/rails server -p 3000 -b ::`, but this way Dawarich won't be able to start Prometheus Exporter. If you want to use Prometheus monitoring, you need to use `bin/dev` command instead. - -Example: - -```diff - dawarich_app: - image: freikin/dawarich:latest -... -- command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] -+ command: ['bin/dev'] -``` - -# 0.22.1 - 2025-01-09 - -### Removed - -- Gems caching volume from the `docker-compose.yml` file. - -To update existing `docker-compose.yml` to new changes, refer to the following: - -```diff - dawarich_app: - image: freikin/dawarich:latest -... - volumes: -- - dawarich_gem_cache_app:/usr/local/bundle/gems -... - dawarich_sidekiq: - image: freikin/dawarich:latest -... - volumes: -- - dawarich_gem_cache_app:/usr/local/bundle/gems -... - -volumes: - dawarich_db_data: -- dawarich_gem_cache_app: -- dawarich_gem_cache_sidekiq: - dawarich_shared: - dawarich_public: - dawarich_watched: -``` - -### Changed - -- `GET /api/v1/health` endpoint now returns a `X-Dawarich-Response: Hey, Im alive and authenticated!` header if user is authenticated. - -# 0.22.0 - 2025-01-09 - -⚠️ This release introduces a breaking change. ⚠️ - -Please read this release notes carefully before upgrading. - -Docker-related files were moved to the `docker` directory and some of them were renamed. Before upgrading, study carefully changes in the `docker/docker-compose.yml` file and update your docker-compose file accordingly, so it uses the new files and commands. Copying `docker/docker-compose.yml` blindly may lead to errors. - -No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues. - -To update existing `docker-compose.yml` to new changes, refer to the following: - -```diff - dawarich_app: - image: freikin/dawarich:latest -... -- entrypoint: dev-entrypoint.sh -- command: ['bin/dev'] -+ entrypoint: web-entrypoint.sh -+ command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] -... - dawarich_sidekiq: - image: freikin/dawarich:latest -... -- entrypoint: dev-entrypoint.sh -- command: ['bin/dev'] -+ entrypoint: sidekiq-entrypoint.sh -+ command: ['bundle', 'exec', 'sidekiq'] -``` - -Although `docker-compose.production.yml` was added, it's not being used by default. It's just an example of how to configure Dawarich for production. The default `docker-compose.yml` file is still recommended for running the app. - -### Changed - -- All docker-related files were moved to the `docker` directory. -- Default memory limit for `dawarich_app` and `dawarich_sidekiq` services was increased to 4GB. -- `dawarich_app` and `dawarich_sidekiq` services now use separate entrypoint scripts. -- Gems (dependency libraries) are now being shipped as part of the Dawarich Docker image. - -### Fixed - -- Visit suggesting job does nothing if user has no tracked points. -- `BulkStatsCalculationJob` now being called without arguments in the data migration. - -### Added - -- A proper production Dockerfile, docker-compose and env files. - -# 0.21.6 - 2025-01-07 - -### Changed - -- Disabled visit suggesting job after import. -- Improved performance of the `User#years_tracked` method. - -### Fixed - -- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605 -- Points are now being rendered with higher z-index than polylines. #577 -- Run cache cleaning and preheating jobs only on server start. #594 - -# 0.21.5 - 2025-01-07 - -You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/). - -### Added - -- Geoapify API support for reverse geocoding. Provide `GEOAPIFY_API_KEY` env var to use it. - -### Removed - -- Photon ENV vars from the `.env.development` and docker-compose.yml files. -- `APPLICATION_HOST` env var. -- `REVERSE_GEOCODING_ENABLED` env var. - -# 0.21.4 - 2025-01-05 - -### Fixed - -- Fixed a bug where Photon API for patreon supporters was not being used for reverse geocoding. - -# 0.21.3 - 2025-01-04 - -### Added - -- A notification about Photon API being under heavy load. - -### Removed - -- The notification about telemetry being enabled. - -### Reverted - -- ~~Imported points will now be reverse geocoded only after import is finished.~~ - -# 0.21.2 - 2024-12-25 - -### Added - -- Logging for Immich responses. -- Watcher now supports all data formats that can be imported via web interface. - -### Changed - -- Imported points will now be reverse geocoded only after import is finished. - -### Fixed - -- Markers on the map are now being rendered with higher z-index than polylines. #577 - -# 0.21.1 - 2024-12-24 - -### Added - -- Cache cleaning and preheating upon application start. -- `PHOTON_API_KEY` env var to set Photon API key. It's an optional env var, but it's required if you want to use Photon API as a Patreon supporter. -- 'X-Dawarich-Response' header to the `GET /api/v1/health` endpoint. It's set to 'Hey, I\'m alive!' to make it easier to check if the API is working. - -### Changed - -- Custom config for PostgreSQL is now optional in `docker-compose.yml`. - -# 0.21.0 - 2024-12-20 - -⚠️ This release introduces a breaking change. ⚠️ - -The `dawarich_db` service now uses a custom `postgresql.conf` file. - -As @tabacha pointed out in #549, the default `shm_size` for the `dawarich_db` service is too small and it may lead to database performance issues. This release introduces a `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. Also, it introduces a custom `postgresql.conf` file to the `dawarich_db` service. - -To mount a custom `postgresql.conf` file, you need to create a `postgresql.conf` file in the `dawarich_db` service directory and add the following line to it: - -```diff - dawarich_db: - image: postgres:14.2-alpine - shm_size: 1G - container_name: dawarich_db - volumes: - - dawarich_db_data:/var/lib/postgresql/data - - dawarich_shared:/var/shared -+ - ./postgresql.conf:/etc/postgresql/postgres.conf # Provide path to custom config - ... - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ] - interval: 10s - retries: 5 - start_period: 30s - timeout: 10s -+ command: postgres -c config_file=/etc/postgresql/postgres.conf # Use custom config -``` - -To ensure your database is using custom config, you can connect to the container (`docker exec -it dawarich_db psql -U postgres`) and run `SHOW config_file;` command. It should return the following path: `/etc/postgresql/postgresql.conf`. - -An example of a custom `postgresql.conf` file is provided in the `postgresql.conf.example` file. - -### Added - -- A button on a year stats card to update stats for the whole year. #466 -- A button on a month stats card to update stats for a specific month. #466 -- A confirmation alert on the Notifications page before deleting all notifications. -- A `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. - -```diff - ... - dawarich_db: - image: postgres:14.2-alpine -+ shm_size: 1G - ... -``` - -- In addition to `api_key` parameter, `Authorization` header is now being used to authenticate API requests. #543 - -Example: - -``` -Authorization: Bearer YOUR_API_KEY -``` - -### Changed - -- The map borders were expanded to make it easier to scroll around the map for New Zealanders. -- The `dawarich_db` service now uses a custom `postgresql.conf` file. -- The popup over polylines now shows dates in the user's format, based on their browser settings. - -# 0.20.2 - 2024-12-17 - -### Added - -- A point id is now being shown in the point popup. - -### Fixed - -- North Macedonia is now being shown on the scratch map. #537 - -### Changed - -- The app process is now bound to :: instead of 0.0.0.0 to provide compatibility with IPV6. -- The app was updated to use Rails 8.0.1. - -# 0.20.1 - 2024-12-16 - -### Fixed - -- Setting `reverse_geocoded_at` for points that don't have geodata is now being performed in background job, in batches of 10,000 points to prevent memory exhaustion and long-running data migration. - -# 0.20.0 - 2024-12-16 - -### Added - -- `GET /api/v1/points/tracked_months` endpoint added to get list of tracked years and months. -- `GET /api/v1/countries/visited_cities` endpoint added to get list of visited cities. -- A link to the docs leading to a help chart for k8s. #550 -- A button to delete all notifications. #548 -- A support for `RAILS_LOG_LEVEL` env var to change log level. More on that here: https://guides.rubyonrails.org/debugging_rails_applications.html#log-levels. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`, corresponding to the log level numbers from 0 up to 5, respectively. The default log level is `:debug`. #540 -- A devcontainer to improve developers experience. #546 - -### Fixed - -- A point popup is no longer closes when hovering over a polyline. #536 -- When polylines layer is disabled and user deletes a point from its popup, polylines layer is no longer being enabled right away. #552 -- Paths to gems within the sidekiq and app containers. #499 - -### Changed - -- Months and years navigation is moved to a map panel on the right side of the map. -- List of visited cities is now being shown in a map panel on the right side of the map. - -# 0.19.7 - 2024-12-11 - -### Fixed - -- Fixed a bug where upon deleting a point on the map, the confirmation dialog was shown multiple times and the point was not being deleted from the map until the page was reloaded. #435 - -### Changed - -- With the "Points" layer enabled on the map, points with negative speed are now being shown in orange color. Since Overland reports negative speed for points that might be faulty, this should help you to identify them. -- On the Points page, speed of the points with negative speed is now being shown in red color. - -# 0.19.6 - 2024-12-11 - -⚠️ This release introduces a breaking change. ⚠️ - -The `dawarich_shared` volume now being mounted to `/data` instead of `/var/shared` within the container. It fixes Redis data being lost on container restart. - -To change this, you need to update the `docker-compose.yml` file: - -```diff - dawarich_redis: - image: redis:7.0-alpine - container_name: dawarich_redis - command: redis-server - volumes: -+ - dawarich_shared:/data - restart: always - healthcheck: -``` - -Telemetry is now disabled by default. To enable it, you need to set `ENABLE_TELEMETRY` env var to `true`. For those who have telemetry enabled using `DISABLE_TELEMETRY` env var set to `false`, telemetry is now disabled by default. - -### Fixed - -- Flash messages are now being removed after 5 seconds. -- Fixed broken migration that was preventing the app from starting. -- Visits page is now loading a lot faster than before. -- Redis data should now be preserved on container restart. -- Fixed a bug where export files could have double extension, e.g. `file.gpx.gpx`. - -### Changed - -- Places page is now accessible from the Visits & Places tab on the navbar. -- Exporting process is now being logged. -- `ENABLE_TELEMETRY` env var is now used instead of `DISABLE_TELEMETRY` to enable/disable telemetry. - -# 0.19.5 - 2024-12-10 - -### Fixed - -- Fixed a bug where the map and visits pages were throwing an error due to incorrect approach to distance calculation. - -# 0.19.4 - 2024-12-10 - -⚠️ This release introduces a breaking change. ⚠️ - -The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of the response: - -```diff -{ - id: 1, - latitude: 10, - longitude: 10, - localDateTime: "2024-01-01T00:00:00Z", - originalFileName: "photo.jpg", - city: "Berlin", - state: "Berlin", - country: "Germany", - type: "image", -+ orientation: "portrait", - source: "photoprism" -} -``` - -### Fixed - -- Fixed a bug where the Photoprism photos were not being shown on the trip page. -- Fixed a bug where the Immich photos were not being shown on the trip page. -- Fixed a bug where the route popup was showing distance in kilometers instead of miles. #490 - -### Added - -- A link to the Photoprism photos on the trip page if there are any. -- A `orientation` field in the Api::PhotoSerializer, hence the `GET /api/v1/photos` endpoint now includes the orientation of the photo. Valid values are `portrait` and `landscape`. -- Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI. -- `DISABLE_TELEMETRY` env var to disable telemetry. More on telemetry: https://dawarich.app/docs/tutorials/telemetry -- `reverse_geocoded_at` column added to the `points` table. - -### Changed - -- On the Stats page, the "Reverse geocoding" section is now showing the number of points that were reverse geocoded based on `reverse_geocoded_at` column, value of which is based on the time when the point was reverse geocoded. If no geodata for the point is available, `reverse_geocoded_at` will be set anyway. Number of points that were reverse geocoded but no geodata is available for them is shown below the "Reverse geocoded" number. - - -# 0.19.3 - 2024-12-06 - -### Changed - -- Refactored stats calculation to calculate only necessary stats, instead of calculating all stats -- Stats are now being calculated every 1 hour instead of 6 hours -- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points. -- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion. - -### Added - -- In-app notification about telemetry being enabled. - -# 0.19.2 - 2024-12-04 - -## The Telemetry release - -Dawarich now can collect usage metrics and send them to InfluxDB. Before this release, the only metrics that could be somehow tracked by developers (only @Freika, as of now) were the number of stars on GitHub and the overall number of docker images being pulled, across all versions of Dawarich, non-splittable by version. New in-app telemetry will allow us to track more granular metrics, allowing me to make decisions based on facts, not just guesses. - -I'm aware about the privacy concerns, so I want to be very transparent about what data is being sent and how it's used. - -Data being sent: - -- Number of DAU (Daily Active Users) -- App version -- Instance ID (unique identifier of the Dawarich instance built by hashing the api key of the first user in the database) - -The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone. - -Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity. - -The telemetry is enabled by default, but it **can be disabled** by setting `DISABLE_TELEMETRY` env var to `true`. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the [telemetry page](https://dawarich.app/docs/tutorials/telemetry) of the website docs. - -### Added - -- Telemetry feature. It's now collecting usage metrics and sending them to InfluxDB. - -# 0.19.1 - 2024-12-04 - -### Fixed - -- Sidekiq is now being correctly exported to Prometheus with `PROMETHEUS_EXPORTER_ENABLED=true` env var in `dawarich_sidekiq` service. - -# 0.19.0 - 2024-12-04 - -## The Photoprism integration release - -⚠️ This release introduces a breaking change. ⚠️ -The `GET /api/v1/photos` endpoint now returns following structure of the response: - -```json -[ - { - "id": "1", - "latitude": 11.22, - "longitude": 12.33, - "localDateTime": "2024-01-01T00:00:00Z", - "originalFileName": "photo.jpg", - "city": "Berlin", - "state": "Berlin", - "country": "Germany", - "type": "image", // "image" or "video" - "source": "photoprism" // "photoprism" or "immich" - } -] -``` - -### Added - -- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). -- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process. - -### Fixed - -- z-index on maps so they won't overlay notifications dropdown -- Redis connectivity where it's not required - -# 0.18.2 - 2024-11-29 - -### Added - -- Demo account. You can now login with `demo@dawarich.app` / `password` to see how Dawarich works. This replaces previous default credentials. - -### Changed - -- The login page now shows demo account credentials if `DEMO_ENV` env var is set to `true`. - -# 0.18.1 - 2024-11-29 - -### Fixed - -- Fixed a bug where the trips interface was breaking when Immich integration is not configured. - -### Added - -- Flash messages are now being shown on the map when Immich integration is not configured. - -# 0.18.0 - 2024-11-28 - -## The Trips release - -You can now create, edit and delete trips. To create a trip, click on the "New Trip" button on the Trips page. Provide a name, date and time for start and end of the trip. You can add your own notes to the trip as well. - -If you have points tracked during provided timeframe, they will be automatically added to the trip and will be shown on the trip map. - -Also, if you have Immich integrated, you will see photos from the trip on the trip page, along with a link to look at them on Immich. - -### Added - -- The Trips feature. Read above for more details. - -### Changed - -- Maps are now not so rough on the edges. - -# 0.17.2 - 2024-11-27 - -### Fixed - -- Retrieving photos from Immich now using `takenAfter` and `takenBefore` instead of `createdAfter` and `createdBefore`. With `createdAfter` and `createdBefore` Immich was returning no items some years. - -# 0.17.1 - 2024-11-27 - -### Fixed - -- Retrieving photos from Immich now correctly handles cases when Immich returns no items. It also logs the response from Immich for debugging purposes. - -# 0.17.0 - 2024-11-26 - -## The Immich Photos release - -With this release, Dawarich can now show photos from your Immich instance on the map. - -To enable this feature, you need to provide your Immich instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). - -An important note to add here is that photos are heavy and hence generate a lot of traffic. The response from Immich for specific dates is being cached in Redis for 1 day, and that may lead to Redis taking a lot more space than previously. But since the cache is being expired after 24 hours, you'll get your space back pretty soon. - -The other thing worth mentioning is how Dawarich gets data from Immich. It goes like this: - -1. When you click on the "Photos" layer, Dawarich will make a request to `GET /api/v1/photos` endpoint to get photos for the selected timeframe. -2. This endpoint will make a request to `POST /search/metadata` endpoint of your Immich instance to get photos for the selected timeframe. -3. The response from Immich is being cached in Redis for 1 day. -4. Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. The number of requests to this endpoint will depend on how many photos you have in the selected timeframe. -5. For each photo, Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. This thumbnail request is also cached in Redis for 1 day. - - -### Added - -- If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled. -- `GET /api/v1/photos` endpoint added to get photos from Immich. -- `GET /api/v1/photos/:id/thumbnail.jpg` endpoint added to get photo thumbnail from Immich. - -# 0.16.9 - 2024-11-24 - -### Changed - -- Rate limit for the Photon API is now 1 request per second. If you host your own Photon API instance, reverse geocoding requests will not be limited. -- Requests to the Photon API are now have User-Agent header set to "Dawarich #{APP_VERSION} (https://dawarich.app)" - -# 0.16.8 - 2024-11-20 - -### Changed - -- Default number of Puma workers is now 2 instead of 1. This should improve the performance of the application. If you have a lot of users, you might want to increase the number of workers. You can do this by setting the `WEB_CONCURRENCY` env var in your `docker-compose.yml` file. Example: - -```diff - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - environment: - ... - WEB_CONCURRENCY: "2" -``` - -# 0.16.7 - 2024-11-20 - -### Changed - -- Prometheus exporter is now bound to 0.0.0.0 instead of localhost -- `PROMETHEUS_EXPORTER_HOST` and `PROMETHEUS_EXPORTER_PORT` env vars were added to the `docker-compose.yml` file to allow you to set the host and port for the Prometheus exporter. They should be added to both `dawarich_app` and `dawarich_sidekiq` services Example: - -```diff - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - environment: - ... - PROMETHEUS_EXPORTER_ENABLED: "true" -+ PROMETHEUS_EXPORTER_HOST: 0.0.0.0 -+ PROMETHEUS_EXPORTER_PORT: "9394" - - dawarich_sidekiq: - image: freikin/dawarich:latest - container_name: dawarich_sidekiq - environment: - ... - PROMETHEUS_EXPORTER_ENABLED: "true" -+ PROMETHEUS_EXPORTER_HOST: dawarich_app -+ PROMETHEUS_EXPORTER_PORT: "9394" -``` - -# 0.16.6 - 2024-11-20 - -### Added - -- Dawarich now can export metrics to Prometheus. You can find the metrics at `your.host:9394/metrics` endpoint. The metrics are being exported in the Prometheus format and can be scraped by Prometheus server. To enable exporting, set the `PROMETHEUS_EXPORTER_ENABLED` env var in your docker-compose.yml to `true`. Example: - -```yaml - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - environment: - ... - PROMETHEUS_EXPORTER_ENABLED: "true" -``` - -# 0.16.5 - 2024-11-18 - -### Changed - -- Dawarich now uses `POST /api/search/metadata` endpoint to get geodata from Immich. - -# 0.16.4 - 2024-11-12 - -### Added - -- Admins can now see all users in the system on the Users page. The path is `/settings/users`. - -### Changed - -- Admins can now provide custom password for new users and update passwords for existing users on the Users page. -- The `bin/dev` file will no longer run `bin/rails tailwindcss:watch` command. It's useful only for development and doesn't really make sense to run it in production. - -### Fixed - -- Exported files will now always have an extension when downloaded. Previously, the extension was missing in case of GPX export. -- Deleting and sorting points on the Points page will now preserve filtering and sorting params when points are deleted or sorted. Previously, the page was being reloaded and filtering and sorting params were lost. - -# 0.16.3 - 2024-11-10 - -### Fixed - -- Make ActionCable respect REDIS_URL env var. Previously, ActionCable was trying to connect to Redis on localhost. - -# 0.16.2 - 2024-11-08 - -### Fixed - -- Exported GPX file now being correctly recognized as valid by Garmin Connect, Adobe Lightroom and (probably) other services. Previously, the exported GPX file was not being recognized as valid by these services. - -# 0.16.1 - 2024-11-08 - -### Fixed - -- Speed is now being recorded into points when a GPX file is being imported. Previously, the speed was not being recorded. -- GeoJSON file from GPSLogger now can be imported to Dawarich. Previously, the import was failing due to incorrect parsing of the file. - -### Changed - -- The Vists suggestion job is disabled. It will be re-enabled in the future with a new approach to the visit suggestion process. - -# 0.16.0 - 2024-11-07 - -## The Websockets release - -### Added - -- New notifications are now being indicated with a blue-ish dot in the top right corner of the screen. Hovering over the bell icon will show you last 10 notifications. -- New points on the map will now be shown in real-time. No need to reload the map to see new points. -- User can now enable or disable Live Mode in the map controls. When Live Mode is enabled, the map will automatically scroll to the new points as they are being added to the map. - -### Changed - -- Scale on the map now shows the distance both in kilometers and miles. - -# 0.15.13 - 2024-11-01 - -### Added - -- `GET /api/v1/countries/borders` endpoint to get countries for scratch map feature - -# 0.15.12 - 2024-11-01 - -### Added - -- Scratch map. You can enable it in the map controls. The scratch map highlight countries you've visited. The scratch map is working properly only if you have your points reverse geocoded. - -# 0.15.11 - 2024-10-29 - -### Added - -- Importing Immich data on the Imports page now will trigger an attempt to write raw json file with the data from Immich to `tmp/imports/immich_raw_data_CURRENT_TIME_USER_EMAIL.json` file. This is useful to debug the problem with the import if it fails. #270 - -### Fixed - -- New app version is now being checked every 6 hours instead of 1 day and the check is being performed in the background. #238 - -### Changed - -- ⚠️ The instruction to import `Records.json` from Google Takeout now mentions `tmp/imports` directory instead of `public/imports`. ⚠️ #326 -- Hostname definition for Sidekiq healtcheck to solve #344. See the diff: - -```diff - dawarich_sidekiq: - image: freikin/dawarich:latest - container_name: dawarich_sidekiq - healthcheck: -- test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep $(hostname)" ] -+ test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep ${HOSTNAME}" ] -``` - -- Renamed directories used by app and sidekiq containers for gems cache to fix #339: - -```diff - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_sidekiq - volumes: -- - gem_cache:/usr/local/bundle/gems -+ - gem_cache:/usr/local/bundle/gems_app - -... - - dawarich_sidekiq: - image: freikin/dawarich:latest - container_name: dawarich_sidekiq - volumes: -- - gem_cache:/usr/local/bundle/gems -+ - gem_cache:/usr/local/bundle/gems_sidekiq -``` - -# 0.15.10 - 2024-10-25 - -### Fixed - -- Data migration that prevented the application from starting. - -# 0.15.9 - 2024-10-24 - -### Fixed - -- Stats distance calculation now correctly calculates the daily distances. - -### Changed - -- Refactored the stats calculation process to make it more efficient. - -# 0.15.8 - 2024-10-22 - -### Added - -- User can now select between "Raw" and "Simplified" mode in the map controls. "Simplified" mode will show less points, improving the map performance. "Raw" mode will show all points. - -# 0.15.7 - 2024-10-19 - -### Fixed - -- A bug where "RuntimeError: failed to get urandom" was being raised upon importing attempt on Synology. - -# 0.15.6 - 2024-10-19 - -### Fixed - -- Import of Owntracks' .rec files now correctly imports points. Previously, the import was failing due to incorrect parsing of the file. - -# 0.15.5 - 2024-10-16 - -### Fixed - -- Fixed a bug where Google Takeout import was failing due to unsupported date format with milliseconds in the file. -- Fixed a bug that prevented using the Photon API host with http protocol. Now you can use both http and https protocols for the Photon API host. You now need to explicitly provide `PHOTON_API_USE_HTTPS` to be `true` or `false` depending on what protocol you want to use. [Example](https://github.com/Freika/dawarich/blob/master/docker-compose.yml#L116-L117) is in the `docker-compose.yml` file. - -### Changed - -- The Map page now by default uses timeframe based on last point tracked instead of the today's points. If there are no points, the map will use the today's timeframe. -- The map on the Map page can no longer be infinitely scrolled horizontally. #299 - -# 0.15.4 - 2024-10-15 - -### Changed - -- Use static version of `geocoder` library that supports http and https for Photon API host. This is a temporary solution until the change is available in a stable release. - -### Added - -- Owntracks' .rec files now can be imported to Dawarich. The import process is the same as for other kinds of files, just select the .rec file and choose "owntracks" as a source. - -### Removed - -- Owntracks' .json files are no longer supported for import as Owntracks itself does not export to this format anymore. - -# 0.15.3 - 2024-10-05 - -To expose the watcher functionality to the user, a new directory `/tmp/imports/watched/` was created. Add new volume to the `docker-compose.yml` file to expose this directory to the host machine. - -```diff - ... - - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - volumes: - - gem_cache:/usr/local/bundle/gems - - public:/var/app/public -+ - watched:/var/app/tmp/watched - - ... - - dawarich_sidekiq: - image: freikin/dawarich:latest - container_name: dawarich_sidekiq - volumes: - - gem_cache:/usr/local/bundle/gems - - public:/var/app/public -+ - watched:/var/app/tmp/watched - - ... - -volumes: - db_data: - gem_cache: - shared_data: - public: -+ watched: -``` - -### Changed - -- Watcher now looks into `/tmp/imports/watched/USER@EMAIL.TLD` directory instead of `/tmp/imports/watched/` to allow using arbitrary file names for imports - -# 0.15.1 - 2024-10-04 - -### Added - -- `linux/arm/v7` is added to the list of supported architectures to support Raspberry Pi 4 and other ARMv7 devices - -# 0.15.0 - 2024-10-03 - -## The Watcher release - -The /public/imporst/watched/ directory is watched by Dawarich. Any files you put in this directory will be imported into the database. The name of the file must start with an email of the user you want to import the file for. The email must be followed by an underscore symbol (_) and the name of the file. - -For example, if you want to import a file for the user with the email address "email@dawarich.app", you would name the file "email@dawarich.app_2024-05-01_2024-05-31.gpx". The file will be imported into the database and the user will receive a notification in the app. - -Both GeoJSON and GPX files are supported. - - -### Added - -- You can now put your GPX and GeoJSON files to `tmp/imports/watched` directory and Dawarich will automatically import them. This is useful if you have a service that can put files to the directory automatically. The directory is being watched every 60 minutes for new files. - -### Changed - -- Monkey patch for Geocoder to support http along with https for Photon API host was removed becausee it was breaking the reverse geocoding process. Now you can use only https for the Photon API host. This might be changed in the future -- Disable retries for some background jobs - -### Fixed - -- Stats update is now being correctly triggered every 6 hours - -# [0.14.7] - 2024-10-01 - -### Fixed - -- Now you can use http protocol for the Photon API host if you don't have SSL certificate for it -- For stats, total distance per month might have been not equal to the sum of distances per day. Now it's fixed and values are equal -- Mobile view of the map looks better now - - -### Changed - -- `GET /api/v1/points` can now accept optional `?order=asc` query parameter to return points in ascending order by timestamp. `?order=desc` is still available to return points in descending order by timestamp -- `GET /api/v1/points` now returns `id` attribute for each point - -# [0.14.6] - 2024-29-30 - -### Fixed - -- Points imported from Google Location History (mobile devise) now have correct timestamps - -### Changed - -- `GET /api/v1/points?slim=true` now returns `id` attribute for each point - -# [0.14.5] - 2024-09-28 - -### Fixed - -- GPX export now finishes correctly and does not throw an error in the end -- Deleting points from the Points page now preserves `start_at` and `end_at` values for the routes. #261 -- Visits map now being rendered correctly in the Visits page. #262 -- Fixed issue with timezones for negative UTC offsets. #194, #122 -- Point page is no longer reloads losing provided timestamps when searching for points on Points page. #283 - -### Changed - -- Map layers from Stadia were disabled for now due to necessary API key - -# [0.14.4] - 2024-09-24 - -### Fixed - -- GPX export now has time and elevation elements for each point - -### Changed - -- `GET /api/v1/points` will no longer return `raw_data` attribute for each point as it's a bit too much - -### Added - -- "Slim" version of `GET /api/v1/points`: pass optional param `?slim=true` to it and it will return only latitude, longitude and timestamp - - -# [0.14.3] — 2024-09-21 - -### Fixed - -- Optimize order of the dockerfiles to leverage layer caching by @JoeyEamigh -- Add support for alternate postgres ports and db names in docker by @JoeyEamigh -- Creating exports directory if it doesn't exist by @tetebueno - - -## [0.14.1] — 2024-09-16 - -### Fixed - -- Fixed a bug where the map was not loading due to invalid tile layer name - - -## [0.14.0] — 2024-09-15 - -### Added - -- 17 new tile layers to choose from. Now you can select the tile layer that suits you the best. You can find the list of available tile layers in the map controls in the top right corner of the map under the layers icon. - - -## [0.13.7] — 2024-09-15 - -### Added - -- `GET /api/v1/points` response now will include `X-Total-Pages` and `X-Current-Page` headers to make it easier to work with the endpoint -- The Pages point now shows total number of points found for provided date range - -## Fixed - -- Link to Visits page in notification informing about new visit suggestion - - -## [0.13.6] — 2024-09-13 - -### Fixed - -- Flatten geodata retrieved from Immich before processing it to prevent errors - - -## [0.13.5] — 2024-09-08 - -### Added - -- Links to view import points on the map and on the Points page on the Imports page. - -### Fixed - -- The Imports page now loading faster. - -### Changed - -- Default value for `RAILS_MAX_THREADS` was changed to 10. -- Visit suggestions background job was moved to its own low priority queue to prevent it from blocking other jobs. - - -## [0.13.4] — 2024-09-06 - -### Fixed - -- Fixed a bug preventing the application from starting, when there is no users in the database but a data migration tries to update one. - - -## [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" -``` - -### Changed - -- Default time range on the map is now 1 day instead of 1 month. It will help you with performance issues if you have a lot of points in the database. - - -## [0.13.2] — 2024-09-06 - -### Fixed - -- GeoJSON import now correctly imports files with FeatureCollection as a root object - -### Changed - -- The Points page now have number of points found for provided date range - -## [0.13.1] — 2024-09-05 - -### Added - -- `GET /api/v1/health` endpoint to check the health of the application with swagger docs - -### Changed - -- Ruby version updated to 3.3.4 -- Visits suggestion process now will try to merge consecutive visits to the same place into one visit. - - -## [0.13.0] — 2024-09-03 - -The GPX and GeoJSON export release - -⚠️ BREAKING CHANGES: ⚠️ - -Default exporting format is now GeoJSON instead of Owntracks-like JSON. This will allow you to use the exported data in other applications that support GeoJSON format. It's also important to highlight, that GeoJSON format does not describe a way to store any time-related data. Dawarich relies on the `timestamp` field in the GeoJSON format to determine the time of the point. The value of the `timestamp` field should be a Unix timestamp in seconds. If you import GeoJSON data that does not have a `timestamp` field, the point will not be imported. - -Example of a valid point in GeoJSON format: - -```json -{ - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [13.350110811262352, 52.51450815] - }, - "properties": { - "timestamp": 1725310036 - } -} -``` - -### Added - -- GeoJSON format is now available for exporting data. -- GPX format is now available for exporting data. -- Importing GeoJSON is now available. - -### Changed - -- Default exporting format is now GeoJSON instead of Owntracks-like JSON. This will allow you to use the exported data in other applications that support GeoJSON format. - -### Fixed - -- Fixed a bug where the confirmation alert was shown more than once when deleting a point. - - -## [0.12.3] — 2024-09-02 - -### Added - -- Resource limits to docke-compose.yml file to prevent server overload. Feel free to adjust the limits to your needs. - -```yml -deploy: - resources: - limits: - cpus: '0.50' # Limit CPU usage to 50% of one core - memory: '2G' # Limit memory usage to 2GB -``` - -### Fixed - -- Importing geodata from Immich will now not throw an error in the end of the process - -### Changed - -- A notification about an existing import with the same name will now show the import name -- Export file now also will contain `raw_dat` field for each point. This field contains the original data that was imported to the application. - - -## [0.12.2] — 2024-08-28 - -### Added - -- `PATCH /api/v1/settings` endpoint to update user settings with swagger docs -- `GET /api/v1/settings` endpoint to get user settings with swagger docs -- Missing `page` and `per_page` query parameters to the `GET /api/v1/points` endpoint swagger docs - -### Changed - -- Map settings moved to the map itself and are available in the top right corner of the map under the gear icon. - - -## [0.12.1] — 2024-08-25 - -### Fixed - -- Fixed a bug that prevented data migration from working correctly - -## [0.12.0] — 2024-08-25 - -### The visit suggestion release - -1. With this release deployment, data migration will work, starting visits suggestion process for all users. -2. After initial visit suggestion process, new suggestions will be calculated every 24 hours, based on points for last 24 hours. -3. If you have enabled reverse geocoding and (optionally) provided Photon Api Host, Dawarich will try to reverse geocode your visit and suggest specific places you might have visited, such as cafes, restaurants, parks, etc. If reverse geocoding is not enabled, or Photon Api Host is not provided, Dawarich will not try to suggest places but you'll be able to rename the visit yourself. -4. You can confirm or decline the visit suggestion. If you confirm the visit, it will be added to your timeline. If you decline the visit, it will be removed from your timeline. You'll be able to see all your confirmed, declined and suggested visits on the Visits page. - - -### Added - -- A "Map" button to each visit on the Visits page to allow user to see the visit on the map -- Visits suggestion functionality. Read more on that in the release description -- Click on the visit name allows user to rename the visit -- Tabs to the Visits page to allow user to switch between confirmed, declined and suggested visits -- Places page to see and delete places suggested by Dawarich's visit suggestion process -- Importing a file will now trigger the visit suggestion process for the user - -## [0.11.2] — 2024-08-22 - -### Changed - -### Fixed - -- Dawarich export was failing when attempted to be imported back to Dawarich. -- Imports page with a lot of imports should now load faster. - - -## [0.11.1] — 2024-08-21 - -### Changed - -- `/api/v1/points` endpoint now returns 100 points by default. You can specify the number of points to return by passing the `per_page` query parameter. Example: `/api/v1/points?per_page=50` will return 50 points. Also, `page` query parameter is now available to paginate the results. Example: `/api/v1/points?per_page=50&page=2` will return the second page of 50 points. - -## [0.11.0] — 2024-08-21 - -### Added - -- A user can now trigger the import of their geodata from Immich to Dawarich by clicking the "Import Immich data" button in the Imports page. -- A user can now provide a url and an api key for their Immich instance and then trigger the import of their geodata from Immich to Dawarich. This can be done in the Settings page. - -### Changed - -- Table columns on the Exports page were reordered to make it more user-friendly. -- Exports are now being named with this pattern: "export_from_dd.mm.yyyy_to_dd.mm.yyyy.json" where "dd.mm.yyyy" is the date range of the export. -- Notification about any error now will include the stacktrace. - -## [0.10.0] — 2024-08-20 - -### Added - -- The `api/v1/stats` endpoint to get stats for the user with swagger docs - -### Fixed - -- Redis and DB containers are now being automatically restarted if they fail. Update your `docker-compose.yml` if necessary - -```diff - services: - dawarich_redis: - image: redis:7.0-alpine - command: redis-server - networks: - - dawarich - volumes: - - shared_data:/var/shared/redis -+ restart: always - dawarich_db: - image: postgres:14.2-alpine - container_name: dawarich_db - volumes: - - db_data:/var/lib/postgresql/data - - shared_data:/var/shared - networks: - - dawarich - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password -+ restart: always -``` - - -See the [PR](https://github.com/Freika/dawarich/pull/185) or Swagger docs (`/api-docs`) for more information. - -## [0.9.12] — 2024-08-15 - -### Fixed - -- Owntracks points are now being saved to the database with the full attributes -- Existing owntracks points also filled with missing data -- Definition of "reverse geocoded points" is now correctly based on the number of points that have full reverse geocoding data instead of the number of points that have only country and city -- Fixed a bug in gpx importing scipt ([thanks, bluemax!](https://github.com/Freika/dawarich/pull/126)) - -## [0.9.11] — 2024-08-14 - -### Fixed - -- A bug where an attempt to import a Google's Records.json file was failing due to wrong object being passed to a background worker - -## [0.9.10] — 2024-08-14 - -### Added - -- PHOTON_API_HOST env variable to set the host of the Photon API. It will allow you to use your own Photon API instance instead of the default one. - -## [0.9.9] — 2024-07-30 - -### Added - -- Pagination to exports page -- Pagination to imports page -- GET `/api/v1/points` endpoint to get all points for the user with swagger docs -- DELETE `/api/v1/points/:id` endpoint to delete a single point for the user with swagger docs -- DELETE `/api/v1/areas/:id` swagger docs -- User can now change route opacity in settings -- Points on the Points page can now be ordered by oldest or newest points -- Visits on the Visits page can now be ordered by oldest or newest visits - -### Changed - -- Point deletion is now being done using an api key instead of CSRF token - -### Fixed - -- OpenStreetMap layer is now being selected by default in map controls - ---- - -## [0.9.8] — 2024-07-27 - -### Fixed - -- Call to the background job to calculate visits - ---- - -## [0.9.7] — 2024-07-27 - -### Fixed - -- Name of background job to calculate visits - ---- - -## [0.9.6] — 2024-07-27 - -### Fixed - -- Map areas functionality - ---- - -## [0.9.5] — 2024-07-27 - -### Added - -- A possibility to create areas. To create an area, click on the Areas checkbox in map controls (top right corner of the map), then in the top left corner of the map, click on a small circle icon. This will enable draw tool, allowing you to draw an area. When you finish drawing, release the mouse button, and the area will be created. Click on the area, set the name and click "Save" to save the area. You can also delete the area by clicking on the trash icon in the area popup. -- A background job to calculate your visits. This job will calculate your visits based on the areas you've created. -- Visits page. This page will show you all your visits, calculated based on the areas you've created. You can see the date and time of the visit, the area you've visited, and the duration of the visit. -- A possibility to confirm or decline a visit. When you create an area, the visit is not calculated immediately. You need to confirm or decline the visit. You can do this on the Visits page. Click on the visit, then click on the "Confirm" or "Decline" button. If you confirm the visit, it will be added to your timeline. If you decline the visit, it will be removed from your timeline. -- Settings for visit calculation. You can set the minimum time spent in the area to consider it as a visit. This setting can be found in the Settings page. -- POST `/api/v1/areas` and GET `/api/v1/areas` endpoints. You can now create and list your areas via the API. - -⚠️ Visits functionality is still in beta. If you find any issues, please let me know. ⚠️ - -### Fixed - -- A route popup now correctly shows distance made in the route, not the distance between first and last points in the route. - ---- - -## [0.9.4] — 2024-07-21 - -### Added - -- A popup being shown when user clicks on a point now contains a link to delete the point. This is useful if you want to delete a point that was imported by mistake or you just want to clean up your data. - -### Fixed - -- Added `public/imports` and `public/exports` folders to git to prevent errors when exporting data - -### Changed - -- Some code from `maps_controller.js` was extracted into separate files - ---- - - -## [0.9.3] — 2024-07-19 - -### Added - -- Admin flag to the database. Now not only the first user in the system can create new users, but also users with the admin flag set to true. This will make easier introduction of more admin functions in the future. - -### Fixed - -- Route hover distance is now being rendered in kilometers, not in meters, if route distance is more than 1 km. - ---- - -## [0.9.2] — 2024-07-19 - -### Fixed - -- Hover over a route does not move map anymore and shows the route tooltip where user hovers over the route, not at the end of the route. Click on route now will move the map to include the whole route. - ---- - -## [0.9.1] — 2024-07-12 - -### Fixed - -- Fixed a bug where total reverse geocoded points were calculated based on number of *imported* points that are reverse geocoded, not on the number of *total* reverse geocoded points. - ---- - -## [0.9.0] — 2024-07-12 - -### Added - -- Background jobs page. You can find it in Settings -> Background Jobs. -- Queue clearing buttons. You can clear all jobs in the queue. -- Reverse geocoding restart button. You can restart the reverse geocoding process for all of your points. -- Reverse geocoding continue button. Click on this button will start reverse geocoding process only for points that were not processed yet. -- A lot more data is now being saved in terms of reverse geocoding process. It will be used in the future to create more insights about your data. - -### Changed - -- Point reference to a user is no longer optional. It should not cause any problems, but if you see any issues, please let me know. -- ⚠️ Calculation of total reverse geocoded points was changed. ⚠️ Previously, the reverse geocoding process was recording only country and city for each point. Now, it records all the data that was received from the reverse geocoding service. This means that the total number of reverse geocoded points will be different from the previous one. It is recommended to restart the reverse geocoding process to get this data for all your existing points. Below you can find an example of what kind of data is being saved to your Dawarich database: - -```json -{ - "place_id": 127850637, - "licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright", - "osm_type": "way", - "osm_id": 718035022, - "lat": "52.51450815", - "lon": "13.350110811262352", - "class": "historic", - "type": "monument", - "place_rank": 30, - "importance": 0.4155071896625501, - "addresstype": "historic", - "name": "Victory Column", - "display_name": "Victory Column, Großer Stern, Botschaftsviertel, Tiergarten, Mitte, Berlin, 10785, Germany", - "address": { - "historic": "Victory Column", - "road": "Großer Stern", - "neighbourhood": "Botschaftsviertel", - "suburb": "Tiergarten", - "borough": "Mitte", - "city": "Berlin", - "ISO3166-2-lvl4": "DE-BE", - "postcode": "10785", - "country": "Germany", - "country_code": "de" - }, - "boundingbox": [ - "52.5142449", - "52.5147775", - "13.3496725", - "13.3505485" - ] -} -``` - ---- - -## [0.8.7] — 2024-07-09 - -### Changed - -- Added a logging config to the `docker-compose.yml` file to prevent logs from overflowing the disk. Now logs are being rotated and stored in the `log` folder in the root of the application. You can find usage example in the the repository's `docker-compose.yml` [file](https://github.com/Freika/dawarich/blob/master/docker-compose.yml#L50). Make sure to add this config to both `dawarich_app` and `dawarich_sidekiq` services. - -```yaml - logging: - driver: "json-file" - options: - max-size: "100m" - max-file: "5" -``` - -### Fixed - -- Visiting notifications page now marks this notifications as read - ---- - -## [0.8.6] — 2024-07-08 - -### Added - -- Guide on how to setup a reverse proxy for Dawarich in the `docs/how_to_setup_reverse_proxy.md` file. This guide explains how to set up a reverse proxy for Dawarich using Nginx and Apache2. - -### Removed - -- `MAP_CENTER` env var from the `docker-compose.yml` file. This variable was used to set the default center of the map, but it is not needed anymore, as the map center is now hardcoded in the application. ⚠️ Feel free to remove this variable from your `docker-compose.yml` file. ⚠️ - -### Fixed - -- Fixed a bug where Overland batch payload was not being processed due to missing coordinates in the payload. Now, if the coordinates are missing, the single point is skipped and the rest are being processed. - ---- - -## [0.8.5] — 2024-07-08 - -### Fixed - -- Set `'localhost'` string as a default value for `APPLICATION_HOSTS` environment variable in the `docker-compose.yml` file instead of an array. This is necessary to prevent errors when starting the application. - ---- - -## [0.8.4] — 2024-07-08 - -### Added - -- Support for multiple hosts. Now you can specify the host of the application by setting the `APPLICATION_HOSTS` (note plural form) environment variable in the `docker-compose.yml` file. Example: - -```yaml - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - environment: - APPLICATION_HOSTS: "yourhost.com,www.yourhost.com,127.0.0.1" -``` - -Note, there should be no protocol prefixes in the `APPLICATION_HOSTS` variable, only the hostnames. - -⚠️ It would also be better to migrate your current `APPLICATION_HOST` to `APPLICATION_HOSTS` to avoid any issues in the future, as `APPLICATION_HOST` will be deprecated in the nearest future. ⚠️ - -- Support for HTTPS. Now you can specify the protocol of the application by setting the `APPLICATION_PROTOCOL` environment variable in the `docker-compose.yml` file. Default value is `http` Example: - -```yaml - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - environment: - APPLICATION_PROTOCOL: "https" -``` - -### Fixed - -- Support for a `location-history.json` file from Google Takeout. It turned out, this file could contain not only an object with location data history, but also an array of objects with location data history. Now Dawarich can handle both cases and import the data correctly. - - ---- - -## [0.8.3] — 2024-07-03 - -### Added - -- Notifications system. Now you will receive a notification when an import or export is finished, when stats update is completed and if any error occurs during any of these processes. Notifications are displayed in the top right corner of the screen and are stored in the database. You can see all your notifications on the Notifications page. -- Swagger API docs for `/api/v1/owntracks/points`. You can find the API docs at `/api-docs`. - ---- - -## [0.8.2] — 2024-06-30 - -### Added - -- Google Takeout geodata, taken from a [mobile devise](https://support.google.com/maps/thread/264641290/export-full-location-timeline-data-in-json-or-similar-format-in-the-new-version-of-timeline?hl=en), is now fully supported and can be imported to the Dawarich. The import process is the same as for other kinds of files, just select the JSON file and choose "Google Phone Takeout" as a source. - -### Fixed - -- Fixed a bug where an imported point was not being saved to the database if a point with the same timestamp and already existed in the database even if it was other user's point. - ---- - -## [0.8.1] — 2024-06-30 - -### Added - -- First user in the system can now create new users from the Settings page. This is useful for creating new users without the need to enable registrations. Default password for new users is `password`. - -### Changed - -- Registrations are now disabled by default. On the initial setup, a default user with email `user@domain.com` and password `password` is created. You can change the password in the Settings page. -- On the Imports page, now you can see the real number of points imported. Previously, this number might have not reflect the real number of points imported. - ---- - -## [0.8.0] — 2024-06-25 - -### Added - -- New Settings page to change Dawarich settings. -- New "Fog of War" toggle on the map controls. -- New "Fog of War meters" field in Settings. This field allows you to set the radius in meters around the point to be shown on the map. The map outside of this radius will be covered with a fog of war. - -### Changed - -- Order of points on Points page is now descending by timestamp instead of ascending. - ---- - -## [0.7.1] — 2024-06-20 - -In new Settings page you can now change the following settings: - -- Maximum distance between two points to consider them as one route -- Maximum time between two points to consider them as one route - -### Added - -- New Settings page to change Dawarich settings. - -### Changed - -- Settings link in user menu now redirects to the new Settings page. -- Old settings page is now available undeer Account link in user menu. - ---- - -## [0.7.0] — 2024-06-19 - -## The GPX MVP Release - -This release introduces support for GPX files to be imported. Now you can import GPX files from your devices to Dawarich. The import process is the same as for other kinds of files, just select the GPX file instead and choose "gpx" as a source. Both single-segmented and multi-segmented GPX files are supported. - -⚠️ BREAKING CHANGES: ⚠️ - -- `/api/v1/points` endpoint is removed. Please use `/api/v1/owntracks/points` endpoint to upload your points from OwnTracks mobile app instead. - -### Added - -- Support for GPX files to be imported. - -### Changed - -- Couple of unnecessary params were hidden from route popup and now can be shown using `?debug=true` query parameter. This is useful for debugging purposes. - -### Removed - -- `/exports/download` endpoint is removed. Now you can download your exports directly from the Exports page. -- `/api/v1/points` endpoint is removed. - ---- - -## [0.6.4] — 2024-06-18 - -### Added - -- A link to Dawarich's website in the footer. It ain't much, but it's honest work. - -### Fixed - -- Fixed version badge in the navbar. Now it will show the correct version of the application. - -### Changed - -- Default map center location was changed. - ---- - -## [0.6.3] — 2024-06-14 - -⚠️ IMPORTANT: ⚠️ - -Please update your `docker-compose.yml` file to include the following changes: - -```diff - dawarich_sidekiq: - image: freikin/dawarich:latest - container_name: dawarich_sidekiq - volumes: - - gem_cache:/usr/local/bundle/gems -+ - public:/var/app/public -``` - -### Added - -- Added a line with public volume to sidekiq's docker-compose service to allow sidekiq process to write to the public folder - -### Fixed - -- Fixed a bug where the export file was not being created in the public folder - ---- - -## [0.6.2] — 2024-06-14 - -This is a debugging release. No changes were made to the application. - ---- - -## [0.6.0] — 2024-06-12 - -### Added - -- Exports page to list existing exports download them or delete them - -### Changed - -- Exporting process now is done in the background, so user can close the browser tab and come back later to download the file. The status of the export can be checked on the Exports page. - -ℹ️ Deleting Export file will only delete the file, not the points in the database. ℹ️ - -⚠️ BREAKING CHANGES: ⚠️ - -Volume, exposed to the host machine for placing files to import was changed. See the changes below. - -Path for placing files to import was changed from `tmp/imports` to `public/imports`. - -```diff - ... - - dawarich_app: - image: freikin/dawarich:latest - container_name: dawarich_app - volumes: - - gem_cache:/usr/local/bundle/gems -- - tmp:/var/app/tmp -+ - public:/var/app/public/imports - - ... -``` - -```diff - ... - -volumes: - db_data: - gem_cache: - shared_data: -- tmp: -+ public: -``` - ---- - -## [0.5.3] — 2024-06-10 - -### Added - -- A data migration to remove points with 0.0, 0.0 coordinates. This is necessary to prevent errors when calculating distance in Stats page. - -### Fixed - -- Reworked code responsible for importing "Records.json" file from Google Takeout. Now it is more reliable and faster, and should not throw as many errors as before. - ---- - -## [0.5.2] — 2024-06-08 - -### Added - -- Test version of google takeout importing service for exports from users' phones - ---- - -## [0.5.1] — 2024-06-07 - -### Added - -- Background jobs concurrency now can be set with `BACKGROUND_PROCESSING_CONCURRENCY` env variable in `docker-compose.yml` file. Default value is 10. -- Hand-made favicon - -### Changed - -- Change minutes to days and hours on route popup - -### Fixed - -- Improved speed of "Stats" page loading by removing unnecessary queries - ---- - -## [0.5.0] — 2024-05-31 - -### Added - -- New buttons to quickly move to today's, yesterday's and 7 days data on the map -- "Download JSON" button to points page -- For debugging purposes, now user can use `?meters_between_routes=500` and `?minutes_between_routes=60` query parameters to set the distance and time between routes to split them on the map. This is useful to understand why routes might not be connected on the map. -- Added scale indicator to the map - -### Changed - -- Removed "Your data" page as its function was replaced by "Download JSON" button on the points page -- Hovering over a route now also shows time and distance to next route as well as time and distance to previous route. This allows user to understand why routes might not be connected on the map. - ---- - -## [0.4.3] — 2024-05-30 - -### Added - -- Now user can hover on a route and see when it started, when it ended and how much time it took to travel - -### Fixed - -- Timestamps in export form are now correctly assigned from the first and last points tracked by the user -- Routes are now being split based both on distance and time. If the time between two consecutive points is more than 60 minutes, the route is split into two separate routes. This improves visibility of the routes on the map. - ---- - -## [0.4.2] — 2024-05-29 - -### Changed - -- Routes are now being split into separate one. If distance between two consecutive points is more than 500 meters, the route is split into two separate routes. This improves visibility of the routes on the map. -- Background jobs concurrency is increased from 5 to 10 to speed up the processing of the points. - -### Fixed - -- Point data, accepted from OwnTracks and Overland, is now being checked for duplicates. If a point with the same timestamp and coordinates already exists in the database, it will not be saved. - ---- -## [0.4.1] — 2024-05-25 - -### Added - -- Heatmap layer on the map to show the density of points - ---- - -## [0.4.0] — 2024-05-25 - -**BREAKING CHANGES**: - -- `/api/v1/points` is still working, but will be **deprecated** in nearest future. Please use `/api/v1/owntracks/points` instead. -- All existing points recorded directly to the database via Owntracks or Overland will be attached to the user with id 1. - -### Added - -- Each user now have an api key, which is required to make requests to the API. You can find your api key in your profile settings. -- You can re-generate your api key in your profile settings. -- In your user profile settings you can now see the instructions on how to use the API with your api key for both OwnTracks and Overland. -- Added docs on how to use the API with your api key. Refer to `/api-docs` for more information. -- `POST /api/v1/owntracks/points` endpoint. -- Points are now being attached to a user directly, so you can only see your own points and no other users of your applications can see your points. - -### Changed - -- `/api/v1/overland/batches` endpoint now requires an api key to be passed in the url. You can find your api key in your profile settings. -- All existing points recorded directly to the database will be attached to the user with id 1. -- All stats and maps are now being calculated and rendered based on the user's points only. -- Default `TIME_ZONE` environment variable is now set to 'UTC' in the `docker-compose.yml` file. - -### Fixed - -- Fixed a bug where marker on the map was rendering timestamp without considering the timezone. - ---- - -## [0.3.2] — 2024-05-23 - -### Added - -- Docker volume for importing Google Takeout data to the application - -### Changed - -- Instruction on how to import Google Takeout data to the application - ---- - -## [0.3.1] — 2024-05-23 - -### Added - -- Instruction on how to import Google Takeout data to the application - ---- - -## [0.3.0] — 2024-05-23 - -### Added - -- Add Points page to display all the points as a table with pagination to allow users to delete points -- Sidekiq web interface to monitor background jobs is now available at `/sidekiq` -- Now you can choose a date range of points to be exported - ---- - -## [0.2.6] — 2024-05-23 - -### Fixed - -- Stop selecting `raw_data` column during requests to `imports` and `points` tables to improve performance. - -### Changed - -- Rename PointsController to MapController along with all the views and routes - -### Added - -- Add Points page to display all the points as a table with pagination to allow users to delete points - ---- - -## [0.2.5] — 2024-05-21 - -### Fixed - -- Stop ignoring `raw_data` column during requests to `imports` and `points` tables. This was preventing points from being created. - ---- - -## [0.2.4] — 2024-05-19 - -### Added - -- In right sidebar you can now see the total amount of geopoints aside of kilometers traveled - -### Fixed - -- Improved overall performance if the application by ignoring `raw_data` column during requests to `imports` and `points` tables. - ---- - - -## [0.2.3] — 2024-05-18 - -### Added - -- Now you can import `records.json` file from your Google Takeout archive, not just Semantic History Location JSON files. The import process is the same as for Semantic History Location JSON files, just select the `records.json` file instead and choose "google_records" as a source. - ---- - - -## [0.2.2] — 2024-05-18 - -### Added - -- Swagger docs, can be found at `https:/api-docs` - ---- - -## [0.2.1] — 2024-05-18 - -### Added - -- Cities, visited by user and listed in right sidebar now also have an active link to a date they were visited - -### Fixed - -- Dark/light theme switcher in navbar is now being saved in user settings, so it persists between sessions - ---- - -## [0.2.0] — 2024-05-05 - -*Breaking changes:* - -This release changes how Dawarich handles a city visit threshold. Previously, the `MINIMUM_POINTS_IN_CITY` environment variable was used to determine the minimum *number of points* in a city to consider it as visited. Now, the `MIN_MINUTES_SPENT_IN_CITY` environment variable is used to determine the minimum *minutes* between two points to consider them as visited the same city. - -The logic behind this is the following: if you have a lot of points in a city, it doesn't mean you've spent a lot of time there, especially if your OwnTracks app was in "Move" mode. So, it's better to consider the time spent in a city rather than the number of points. - -In your docker-compose.yml file, you need to replace the `MINIMUM_POINTS_IN_CITY` environment variable with `MIN_MINUTES_SPENT_IN_CITY`. The default value is `60`, in minutes. - ---- - -## [0.1.9] — 2024-04-25 - -### Added - -- A test for CheckAppVersion service class - -### Changed - -- Replaced ActiveStorage with Shrine for file uploads - -### Fixed - -- `ActiveStorage::FileNotFoundError` error when uploading export files - ---- - -## [0.1.8.1] — 2024-04-21 - -### Changed - -- Set Redis as default cache store - -### Fixed - -- Consider timezone when parsing datetime params in points controller -- Add rescue for check version service class - ---- - -## [0.1.8] — 2024-04-21 - -### Added - -- Application version badge to the navbar with check for updates button -- Npm dependencies install to Github build workflow -- Footer - -### Changed - -- Disabled map points rendering by default to improve performance on big datasets - ---- - -## [0.1.7] — 2024-04-17 - -### Added - -- Map controls to toggle polylines and points visibility - -### Changed - -- Added content padding for mobile view -- Fixed stat card layout for mobile view - ---- - -## [0.1.6.3] — 2024-04-07 - -### Changed - -- Removed strong_params from POST /api/v1/points - ---- - -## [0.1.6.1] — 2024-04-06 - -### Fixed - -- `ActiveStorage::FileNotFoundError: ActiveStorage::FileNotFoundError` error when uploading export files - ---- - -## [0.1.6] — 2024-04-06 - -You can now use [Overland](https://overland.p3k.app/) mobile app to track your location. - -### Added - -- Overland API endpoint (POST /api/v1/overland/batches) - -### Changed - -### Fixed - ---- - -## [0.1.5] — 2024-04-05 - -You can now specify the host of the application by setting the `APPLICATION_HOST` environment variable in the `docker-compose.yml` file. - -### Added - -- Added version badge to navbar -- Added APPLICATION_HOST environment variable to docker-compose.yml to allow user to specify the host of the application -- Added CHANGELOG.md to keep track of changes - -### Changed - -- Specified gem version in Docker entrypoint - -### Fixed From 0b08af87af5f429ab5a5184e7080cf19726a0f0b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 23:00:34 +0100 Subject: [PATCH 132/157] Clean up some code --- CHANGELOG.md | 5 +++++ app/assets/stylesheets/application.css | 5 ----- app/javascript/controllers/maps_controller.js | 5 +---- app/javascript/maps/areas.js | 19 ------------------- 4 files changed, 6 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7deee074..4dc05172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ This will select points tracked between January 1st and January 31st 2025. Then - After deleting one point from the map, other points can now be deleted as well. #723 #678 - Fixed a bug where export file was not being deleted from the server after it was deleted. #808 - After an area was drawn on the map, a popup is now being shown to allow user to provide a name and save the area. #740 +- Docker entrypoints now use database name to fix problem with custom database names. + +### Added + +- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. # 0.23.6 - 2025-02-06 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 4016ec54..982d94b0 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -107,8 +107,3 @@ transform: rotate(360deg); } } - -.clickable-area, -.leaflet-interactive { - cursor: pointer !important; -} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 98fde9ea..2e921c86 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -128,7 +128,7 @@ export default class extends Controller { Heatmap: this.heatmapLayer, "Fog of War": new this.fogOverlay(), "Scratch map": this.scratchLayer, - Areas: this.areasLayer, // Add areasLayer to the control + Areas: this.areasLayer, Photos: this.photoMarkers }; @@ -573,9 +573,6 @@ export default class extends Controller { fillOpacity: 0.5, }, }, - }, - edit: { - featureGroup: this.drawnItems } }); diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index a7179297..54e64c59 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -35,7 +35,6 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
`; - console.log('Binding popup to layer'); layer.bindPopup(formHtml, { maxWidth: "auto", minWidth: 300, @@ -44,20 +43,14 @@ export function handleAreaCreated(areasLayer, layer, apiKey) { className: 'area-form-popup' }).openPopup(); - console.log('Adding layer to areasLayer'); areasLayer.addLayer(layer); // Bind the event handler immediately after opening the popup setTimeout(() => { - console.log('Setting up form handlers'); const form = document.getElementById('circle-form'); const saveButton = document.getElementById('save-area-btn'); const nameInput = document.getElementById('circle-name'); - console.log('Form:', form); - console.log('Save button:', saveButton); - console.log('Name input:', nameInput); - if (!form || !saveButton || !nameInput) { console.error('Required elements not found'); return; @@ -77,28 +70,20 @@ export function handleAreaCreated(areasLayer, layer, apiKey) { e.stopPropagation(); if (!nameInput.value.trim()) { - console.log('Name is empty'); nameInput.classList.add('input-error'); return; } - console.log('Creating FormData'); const formData = new FormData(form); - formData.forEach((value, key) => { - console.log(`FormData: ${key} = ${value}`); - }); - console.log('Calling saveArea'); saveArea(formData, areasLayer, layer, apiKey); }); }, 100); // Small delay to ensure DOM is ready } export function saveArea(formData, areasLayer, layer, apiKey) { - console.log('saveArea called with apiKey:', apiKey); const data = {}; formData.forEach((value, key) => { - console.log('FormData entry:', key, value); const keys = key.split('[').map(k => k.replace(']', '')); if (keys.length > 1) { if (!data[keys[0]]) data[keys[0]] = {}; @@ -108,21 +93,18 @@ export function saveArea(formData, areasLayer, layer, apiKey) { } }); - console.log('Sending fetch request with data:', data); fetch(`/api/v1/areas?api_key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json'}, body: JSON.stringify(data) }) .then(response => { - console.log('Received response:', response); if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }) .then(data => { - console.log('Area saved successfully:', data); layer.closePopup(); layer.bindPopup(` Name: ${data.name}
@@ -170,7 +152,6 @@ export function deleteArea(id, areasLayer, layer, apiKey) { } export function fetchAndDrawAreas(areasLayer, apiKey) { - console.log('Fetching areas...'); fetch(`/api/v1/areas?api_key=${apiKey}`, { method: 'GET', headers: { From 1f6d711ccfb42d4c5c05fbb92a4bf8b9e43c6cd6 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 23:01:32 +0100 Subject: [PATCH 133/157] Don't log apiKey in handleAreaCreated --- app/javascript/maps/areas.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js index 54e64c59..66d5442b 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -1,7 +1,6 @@ import { showFlashMessage } from "./helpers"; export function handleAreaCreated(areasLayer, layer, apiKey) { - console.log('handleAreaCreated called with apiKey:', apiKey); const radius = layer.getRadius(); const center = layer.getLatLng(); From c0f9de40573932b303e0b72257ae43b2f51f86c4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 23:27:27 +0100 Subject: [PATCH 134/157] Change default PostgreSQL image to Postgis --- CHANGELOG.md | 6 +- docker/docker-compose.production.yml | 2 +- docker/docker-compose_mounted_volumes.yml | 159 ---------------------- docs/synology/docker-compose.yml | 2 +- 4 files changed, 5 insertions(+), 164 deletions(-) delete mode 100644 docker/docker-compose_mounted_volumes.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc05172..150a9428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,7 +327,7 @@ To mount a custom `postgresql.conf` file, you need to create a `postgresql.conf` ```diff dawarich_db: - image: postgres:14.2-alpine + image: postgis/postgis:14-3.5-alpine shm_size: 1G container_name: dawarich_db volumes: @@ -358,7 +358,7 @@ An example of a custom `postgresql.conf` file is provided in the `postgresql.con ```diff ... dawarich_db: - image: postgres:14.2-alpine + image: postgis/postgis:14-3.5-alpine + shm_size: 1G ... ``` @@ -1299,7 +1299,7 @@ deploy: - shared_data:/var/shared/redis + restart: always dawarich_db: - image: postgres:14.2-alpine + image: postgis/postgis:14-3.5-alpine container_name: dawarich_db volumes: - db_data:/var/lib/postgresql/data diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml index a4b83a34..42b64370 100644 --- a/docker/docker-compose.production.yml +++ b/docker/docker-compose.production.yml @@ -17,7 +17,7 @@ services: start_period: 30s timeout: 10s dawarich_db: - image: postgres:17-alpine + image: postgres:17-alpine # TODO: Use postgis here shm_size: 1G container_name: dawarich_db volumes: diff --git a/docker/docker-compose_mounted_volumes.yml b/docker/docker-compose_mounted_volumes.yml deleted file mode 100644 index ef61f49a..00000000 --- a/docker/docker-compose_mounted_volumes.yml +++ /dev/null @@ -1,159 +0,0 @@ -networks: - dawarich: - - -volumes: - dawarich_public: - name: dawarich_public - dawarich_keydb: - name: dawarich_keydb - dawarich_shared: - name: dawarich_shared - watched: - name: dawarich_watched - -services: - app: - container_name: dawarich_app - image: freikin/dawarich:latest - restart: unless-stopped - depends_on: - db: - condition: service_healthy - restart: true - keydb: - condition: service_healthy - restart: true - networks: - - dawarich - ports: - - 3000:3000 - environment: - TIME_ZONE: Europe/London - RAILS_ENV: development - REDIS_URL: redis://keydb:6379/0 - DATABASE_HOST: db - DATABASE_USERNAME: postgres - DATABASE_PASSWORD: password - DATABASE_NAME: dawarich_development - MIN_MINUTES_SPENT_IN_CITY: 60 - APPLICATION_HOSTS: localhost - APPLICATION_PROTOCOL: http - DISTANCE_UNIT: km - stdin_open: true - tty: true - entrypoint: dev-entrypoint.sh - command: [ 'bin/dev' ] - volumes: - - dawarich_public:/var/app/dawarich_public - - watched:/var/app/tmp/imports/watched - healthcheck: - test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ] - start_period: 60s - interval: 15s - timeout: 5s - retries: 3 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "5" - deploy: - resources: - limits: - cpus: '0.50' # Limit CPU usage to 50% of one core - memory: '2G' # Limit memory usage to 2GB - - sidekiq: - container_name: dawarich_sidekiq - hostname: sidekiq - image: freikin/dawarich:latest - restart: unless-stopped - depends_on: - app: - condition: service_healthy - restart: true - db: - condition: service_healthy - restart: true - keydb: - condition: service_healthy - restart: true - networks: - - dawarich - environment: - RAILS_ENV: development - REDIS_URL: redis://keydb:6379/0 - DATABASE_HOST: db - DATABASE_USERNAME: postgres - DATABASE_PASSWORD: password - DATABASE_NAME: dawarich_development - APPLICATION_HOSTS: localhost - BACKGROUND_PROCESSING_CONCURRENCY: 10 - APPLICATION_PROTOCOL: http - DISTANCE_UNIT: km - stdin_open: true - tty: true - entrypoint: dev-entrypoint.sh - command: [ 'sidekiq' ] - volumes: - - dawarich_public:/var/app/dawarich_public - - watched:/var/app/tmp/imports/watched - logging: - driver: "json-file" - options: - max-size: "100m" - max-file: "5" - healthcheck: - test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep $${HOSTNAME}" ] - interval: 10s - retries: 5 - start_period: 30s - timeout: 10s - deploy: - resources: - limits: - cpus: '0.50' # Limit CPU usage to 50% of one core - memory: '2G' # Limit memory usage to 2GB - - keydb: - container_name: dawarich-keydb - image: eqalpha/keydb:x86_64_v6.3.4 - restart: unless-stopped - networks: - - dawarich - environment: - - TZ=Europe/London - - PUID=1000 - - PGID=1000 - command: keydb-server /etc/keydb/keydb.conf --appendonly yes --server-threads 4 --active-replica no - volumes: - - dawarich_keydb:/data - - dawarich_shared:/var/shared/redis - healthcheck: - test: [ "CMD", "keydb-cli", "ping" ] - start_period: 60s - interval: 15s - timeout: 5s - retries: 3 - - db: - container_name: dawarich-db - hostname: db - image: postgres:16.4-alpine3.20 - restart: unless-stopped - networks: - - dawarich - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DATABASE: dawarich - volumes: - - ./db:/var/lib/postgresql/data - - dawarich_shared:/var/shared - healthcheck: - test: [ "CMD-SHELL", "pg_isready -q -d $${POSTGRES_DATABASE} -U $${POSTGRES_USER} -h localhost" ] - start_period: 60s - interval: 15s - timeout: 5s - retries: 3 diff --git a/docs/synology/docker-compose.yml b/docs/synology/docker-compose.yml index 62092437..5b06bc21 100644 --- a/docs/synology/docker-compose.yml +++ b/docs/synology/docker-compose.yml @@ -10,7 +10,7 @@ services: - ./redis:/var/shared/redis dawarich_db: - image: postgres:14.2-alpine + image: postgis/postgis:14-3.5-alpine container_name: dawarich_db restart: unless-stopped environment: From 6f7b724fceec18058329ff4b7b290cfbf8d357d6 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 23:36:09 +0100 Subject: [PATCH 135/157] Update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150a9428..129bef2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,9 @@ This will convert speed in kilometers per hour to meters per second and round it If you have been using both speed units, but you know the dates where you were tracking with speed in kilometers per hour, on the second step of the instruction above, you can add `where("timestamp BETWEEN ? AND ?", Date.parse("2025-01-01").beginning_of_day.to_i, Date.parse("2025-01-31").end_of_day.to_i)` to the query to convert speed in kilometers per hour to meters per second only for a specific period of time. Resulting query will look like this: ```ruby -points = Point.where(import_id: nil).where.not(velocity: [nil, "0"]).where("timestamp BETWEEN ? AND ?", Date.parse("2025-01-01").beginning_of_day.to_i, Date.parse("2025-01-31").end_of_day.to_i).where("velocity NOT LIKE '%.%'") +start_at = DateTime.new(2025, 1, 1, 0, 0, 0).in_time_zone(Time.current.time_zone).to_i +end_at = DateTime.new(2025, 1, 31, 23, 59, 59).in_time_zone(Time.current.time_zone).to_i +points = Point.where(import_id: nil).where.not(velocity: [nil, "0"]).where("timestamp BETWEEN ? AND ?", start_at, end_at).where("velocity NOT LIKE '%.%'") ``` This will select points tracked between January 1st and January 31st 2025. Then just use step 3 to convert speed in kilometers per hour to meters per second. From b8c69c2a76c885936c052717104eeae21d276c94 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 8 Feb 2025 23:52:37 +0100 Subject: [PATCH 136/157] Fix importing of GPX files with empty tracks and reduce number of points created for other GPX files. --- CHANGELOG.md | 1 + app/services/gpx/track_parser.rb | 2 +- spec/fixtures/files/gpx/garmin_example.gpx | 1 + .../files/gpx/gpx_track_multiple_segments.gpx | 4124 +---------------- .../files/gpx/gpx_track_multiple_tracks.gpx | 2986 +----------- .../files/gpx/gpx_track_single_segment.gpx | 1180 ----- spec/services/gpx/track_parser_spec.rb | 12 +- 7 files changed, 11 insertions(+), 8295 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129bef2e..372d12b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ This will select points tracked between January 1st and January 31st 2025. Then - Fixed a bug where export file was not being deleted from the server after it was deleted. #808 - After an area was drawn on the map, a popup is now being shown to allow user to provide a name and save the area. #740 - Docker entrypoints now use database name to fix problem with custom database names. +- Garmin GPX files with empty tracks are now being imported correctly. #827 ### Added diff --git a/app/services/gpx/track_parser.rb b/app/services/gpx/track_parser.rb index 10f13983..20c2837a 100644 --- a/app/services/gpx/track_parser.rb +++ b/app/services/gpx/track_parser.rb @@ -28,7 +28,7 @@ class Gpx::TrackParser segments = track['trkseg'] segments_array = segments.is_a?(Array) ? segments : [segments] - segments_array.map { |segment| segment['trkpt'] } + segments_array.compact.map { |segment| segment['trkpt'] } end def create_point(point, index) diff --git a/spec/fixtures/files/gpx/garmin_example.gpx b/spec/fixtures/files/gpx/garmin_example.gpx index 04c7a6dd..d3b2ee30 100644 --- a/spec/fixtures/files/gpx/garmin_example.gpx +++ b/spec/fixtures/files/gpx/garmin_example.gpx @@ -27,5 +27,6 @@ 8.8 + diff --git a/spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx b/spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx index 8797d0a2..fbf74bcb 100644 --- a/spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx +++ b/spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx @@ -135,206 +135,6 @@ 0 - - 719 - - 3.8 - - 0 - - - - 719.2 - - 3.8 - - 0 - - - - 719.2 - - 4.2 - - 0 - - - - 719.2 - - 3.8 - - 0 - - - - 719.1 - - 4.2 - - 0 - - - - 719.1 - - 4.4 - - 0 - - - - 719 - - 4.2 - - 0 - - - - 719.1 - - 4.5 - - 0 - - - - 719.1 - - 7.2 - - 0 - - - - 719.1 - - 6.3 - - 0 - - - - 719 - - 5.8 - - 0 - - - - 719.1 - - 5 - - 0 - - - - 719.1 - - 4.6 - - 0 - - - - 719.1 - - 5.1 - - 0 - - - - 719 - - 4.9 - - 0 - - - - 719 - - 4.8 - - 0 - - - - 719 - - 5.5 - - 0 - - - - 719 - - 4.7 - - 0.4 - - - - 719 - - 4.4 - - 0.2 - - - - 719 - - 4.3 - - 0.1 - - - - 719.1 - - 3.9 - - 0 - - - - 719.1 - - 3.8 - - 0 - - - - 719.1 - - 3.9 - - 0 - - - - 719 - - 3.9 - - 0 - - - - 719.1 - - 4 - - 0 - - @@ -441,1262 +241,6 @@ 0 - - 1011.2 - - 4.1 - - 1.7 - - - - 1011 - - 4.1 - - 1.9 - - - - 1011.4 - - 3.8 - - 2.9 - - - - 1013.9 - - 3.8 - - 3.1 - - - - 1015.7 - - 3.8 - - 3.2 - - - - 1018.5 - - 3.8 - - 2.7 - - - - 1019.6 - - 3.8 - - 2.6 - - - - 1022.5 - - 3.8 - - 2.9 - - - - 1022.4 - - 3.8 - - 2.2 - - - - 1021.3 - - 3.8 - - 1.2 - - - - 1023.4 - - 3.8 - - 0.6 - - - - 1022.3 - - 3.8 - - 0.4 - - - - 1024.7 - - 3.8 - - 0.2 - - - - 1024.7 - - 3.8 - - 0.1 - - - - 1025.6 - - 3.8 - - 4.2 - - - - 1027.6 - - 3.8 - - 6.4 - - - - 1027.3 - - 3.8 - - 3.8 - - - - 1028.1 - - 3.8 - - 5.8 - - - - 1029.6 - - 3.8 - - 1.3 - - - - 1028.8 - - 3.8 - - 0.1 - - - - 1029.2 - - 3.8 - - 0.7 - - - - 1027.8 - - 3.8 - - 0.4 - - - - 1028.2 - - 3.8 - - 0.3 - - - - 1028.4 - - 3.8 - - 2.8 - - - - 1029.9 - - 3.8 - - 4.9 - - - - 1031.5 - - 3.8 - - 0.1 - - - - 1031.8 - - 3.8 - - 0.2 - - - - 1032.7 - - 3.8 - - 0.1 - - - - 1032.7 - - 3.8 - - 0 - - - - 1032.4 - - 3.8 - - 2.7 - - - - 1032.8 - - 3.8 - - 2 - - - - 1033.1 - - 3.8 - - 3.1 - - - - 1035.3 - - 3.8 - - 4.2 - - - - 1037 - - 3.8 - - 4.8 - - - - 1039.6 - - 3.8 - - 3.4 - - - - 1041.5 - - 3.8 - - 1.4 - - - - 1041.4 - - 3.8 - - 0 - - - - 1040.5 - - 3.8 - - 0.1 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1040.2 - - 3.8 - - 0 - - - - 1039.9 - - 3.8 - - 3.2 - - - - 1042.2 - - 3.8 - - 5.6 - - - - 1045.7 - - 3.8 - - 5.2 - - - - 1048 - - 3.8 - - 5 - - - - 1048.5 - - 3.8 - - 4 - - - - 1049.7 - - 3.8 - - 0.1 - - - - 1049.9 - - 3.8 - - 0.1 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.2 - - 3.8 - - 0 - - - - 1050.3 - - 3.8 - - 0 - - - - 1050.3 - - 3.8 - - 0 - - - - 1049.9 - - 3.8 - - 0.3 - - - - 1049.9 - - 3.8 - - 0.1 - - - - 1049.3 - - 3.8 - - 0 - - - - 1049.2 - - 3.8 - - 0 - - - - 1049.3 - - 3.8 - - 0 - - - - 1049.8 - - 3.8 - - 1.3 - - - - 1050.6 - - 3.8 - - 3.2 - - - - 1051.7 - - 3.8 - - 4.5 - - - - 1054 - - 3.8 - - 4.6 - - - - 1057.6 - - 3.8 - - 4.3 - - - - 1059.3 - - 3.8 - - 4.8 - - - - 1060.7 - - 3.8 - - 4.3 - - - - 1063.2 - - 3.8 - - 2.3 - - - - 1063.7 - - 3.8 - - 1.7 - - - - 1064.8 - - 3.8 - - 0.6 - - - - 1064.9 - - 3.8 - - 0.3 - - - - 1064.7 - - 3.8 - - 0 - - - - 1064.7 - - 3.8 - - 0 - - - - 1064.7 - - 3.8 - - 0 - - - - 1064.7 - - 3.8 - - 0 - - - - 1064.7 - - 3.8 - - 0 - - - - 1064.7 - - 3.8 - - 0 - - - - 1065.3 - - 3.8 - - 1.4 - - - - 1065.1 - - 3.8 - - 3 - - - - 1063.7 - - 3.8 - - 1.9 - - - - 1065.5 - - 3.8 - - 2 - - - - 1065 - - 3.8 - - 2.3 - - - - 1065.1 - - 3.8 - - 3.2 - - - - 1066.4 - - 3.8 - - 2.6 - - - - 1065.9 - - 3.8 - - 3.9 - - - - 1066.4 - - 3.8 - - 2.9 - - - - 1066.4 - - 3.8 - - 4.1 - - - - 1064.8 - - 3.8 - - 4.2 - - - - 1062.8 - - 3.8 - - 5.8 - - - - 1059.8 - - 3.8 - - 7.3 - - - - 1060.6 - - 3.8 - - 8 - - - - 1060.9 - - 3.8 - - 7.4 - - - - 1060 - - 3.8 - - 7.8 - - - - 1058.2 - - 3.8 - - 5.3 - - - - 1053.7 - - 3.8 - - 7.1 - - - - 1055.1 - - 3.8 - - 6.3 - - - - 1056.1 - - 3.8 - - 7.1 - - - - 1053.5 - - 3.8 - - 5.9 - - - - 1054.6 - - 3.8 - - 2.8 - - - - 1053.8 - - 3.8 - - 4.5 - - - - 1053.2 - - 3.8 - - 5.4 - - - - 1054.2 - - 3.8 - - 5 - - - - 1053.7 - - 3.8 - - 6 - - - - 1053.9 - - 3.8 - - 5.5 - - - - 1054.9 - - 3.8 - - 3 - - - - 1056.4 - - 3.8 - - 4.3 - - - - 1057.2 - - 3.8 - - 2.9 - - - - 1057.5 - - 3.8 - - 3.9 - - - - 1059.1 - - 3.8 - - 6.6 - - - - 1062 - - 3.8 - - 5.9 - - - - 1064.6 - - 3.8 - - 3.9 - - - - 1065.8 - - 3.8 - - 5.3 - - - - 1067.7 - - 3.8 - - 5.4 - - - - 1068.6 - - 3.8 - - 4.1 - - - - 1068.2 - - 3.8 - - 0.9 - - - - 1069.5 - - 3.8 - - 1.5 - - - - 1069.4 - - 3.8 - - 0.1 - - - - 1069.4 - - 3.8 - - 0.1 - - - - 1069.4 - - 3.8 - - 0 - - - - 1068.9 - - 3.8 - - 2 - - - - 1069.9 - - 3.8 - - 4.9 - - - - 1070 - - 3.8 - - 3.4 - - - - 1070.8 - - 3.8 - - 2.5 - - - - 1072.1 - - 3.8 - - 2.3 - - - - 1072.3 - - 3.8 - - 3.2 - - - - 1073.2 - - 3.8 - - 2.2 - - - - 1072.8 - - 3.8 - - 2.6 - - - - 1073.9 - - 3.8 - - 2.2 - - - - 1075.8 - - 3.8 - - 3.7 - - - - 1078.4 - - 3.8 - - 5.2 - - - - 1079.7 - - 3.8 - - 5.7 - - - - 1084.2 - - 3.8 - - 6.3 - - - - 1085.7 - - 3.8 - - 3 - - - - 1086.8 - - 3.8 - - 0.8 - - - - 1086.1 - - 3.9 - - 0.6 - - - - 1085.8 - - 3.8 - - 0.9 - - - - 1086.8 - - 3.8 - - 0.6 - - - - 1086.3 - - 3.8 - - 2.5 - - - - 1088.1 - - 3.8 - - 1.6 - - - - 1087.7 - - 3.8 - - 0.6 - - - - 1087.3 - - 3.8 - - 1.9 - - - - 1086.6 - - 3.8 - - 0.3 - - - - 1086.4 - - 3.8 - - 0.2 - - - - 1085.9 - - 3.8 - - 0.5 - - - - 1085.5 - - 3.8 - - 0.6 - - - - 1084.2 - - 3.8 - - 1 - - - - 1085.8 - - 3.8 - - 0.1 - - @@ -1811,2672 +355,6 @@ 0.7 - - 1085.8 - - 3.8 - - 0.1 - - - - 1085.6 - - 3.8 - - 0 - - - - 1085.6 - - 3.8 - - 0 - - - - 1085.6 - - 3.8 - - 0 - - - - 1084.1 - - 3.8 - - 5.8 - - - - 1081.1 - - 3.8 - - 6.4 - - - - 1081 - - 3.8 - - 6.2 - - - - 1079 - - 3.8 - - 5.3 - - - - 1076.6 - - 3.8 - - 4 - - - - 1074.3 - - 3.8 - - 7.5 - - - - 1071.8 - - 3.8 - - 7.3 - - - - 1069.4 - - 3.8 - - 6.8 - - - - 1066.8 - - 3.8 - - 5.7 - - - - 1063.1 - - 3.8 - - 6.1 - - - - 1061.3 - - 3.8 - - 7.4 - - - - 1058.7 - - 3.8 - - 6.6 - - - - 1056.3 - - 3.8 - - 6.7 - - - - 1052.6 - - 3.8 - - 6.5 - - - - 1054.2 - - 3.8 - - 5.6 - - - - 1054.6 - - 3.8 - - 1.6 - - - - 1054.1 - - 3.8 - - 1.7 - - - - 1054.1 - - 3.9 - - 0.1 - - - - 1053.9 - - 3.8 - - 0 - - - - 1054.7 - - 3.8 - - 3.1 - - - - 1051.4 - - 3.8 - - 3.1 - - - - 1048.3 - - 3.8 - - 2.2 - - - - 1046.8 - - 3.8 - - 1.1 - - - - 1044.3 - - 3.8 - - 1.7 - - - - 1043.5 - - 3.8 - - 0.2 - - - - 1043.1 - - 3.8 - - 0.1 - - - - 1043 - - 3.8 - - 0.3 - - - - 1043 - - 3.8 - - 0.1 - - - - 1043.4 - - 3.8 - - 0.1 - - - - 1043.2 - - 3.8 - - 0.3 - - - - 1042.2 - - 3.8 - - 0.1 - - - - 1041 - - 3.8 - - 1.7 - - - - 1039.8 - - 3.8 - - 1.9 - - - - 1038.7 - - 3.8 - - 0.3 - - - - 1037 - - 3.8 - - 0.3 - - - - 1036.9 - - 3.8 - - 0 - - - - 1036.9 - - 3.8 - - 0 - - - - 1035.5 - - 3.8 - - 3.7 - - - - 1032.2 - - 3.8 - - 3.3 - - - - 1029.1 - - 3.8 - - 3.3 - - - - 1025.4 - - 3.8 - - 2.4 - - - - 1022.2 - - 3.8 - - 6 - - - - 1019.4 - - 3.8 - - 4.6 - - - - 1017 - - 3.8 - - 4.6 - - - - 1014.6 - - 3.8 - - 1.8 - - - - 1011.3 - - 3.8 - - 1.4 - - - - 1010.4 - - 3.8 - - 0.8 - - - - 1006.2 - - 3.8 - - 1 - - - - 1007.5 - - 3.8 - - 0.2 - - - - 1007.7 - - 3.8 - - 0.1 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.5 - - 3.8 - - 0.8 - - - - 1007.5 - - 3.8 - - 0.2 - - - - 1005.9 - - 3.8 - - 0.2 - - - - 1006.2 - - 3.9 - - 0.6 - - - - 1005.9 - - 3.8 - - 0.6 - - - - 1007.4 - - 3.8 - - 0.4 - - - - 1007.1 - - 3.8 - - 0.5 - - - - 1002.9 - - 3.8 - - 0.6 - - - - 1002.7 - - 3.8 - - 1.1 - - - - 1002.6 - - 3.8 - - 0.3 - - - - 1004.1 - - 3.8 - - 0.1 - - - - 1004.4 - - 3.8 - - 0 - - - - 1004.4 - - 3.8 - - 0 - - - - 1002.6 - - 3.8 - - 0.9 - - - - 1002.3 - - 3.8 - - 1.4 - - - - 1001.3 - - 3.8 - - 0.7 - - - - 1000.8 - - 3.8 - - 0.7 - - - - 997.5 - - 3.8 - - 1.5 - - - - 997.2 - - 3.8 - - 0.2 - - - - 996.2 - - 3.8 - - 1.8 - - - - 993.6 - - 3.8 - - 1.6 - - - - 993.9 - - 3.8 - - 0.4 - - - - 990.8 - - 3.8 - - 4.4 - - - - 990.6 - - 3.8 - - 0.1 - - - - 990.4 - - 3.8 - - 0.6 - - - - 989.6 - - 3.8 - - 0.2 - - - - 989.8 - - 3.8 - - 0.1 - - - - 989.7 - - 3.8 - - 0 - - - - 989.6 - - 3.8 - - 0.6 - - - - 989.3 - - 3.8 - - 0.4 - - - - 989.9 - - 3.8 - - 0.2 - - - - 990.2 - - 3.8 - - 0 - - - - 990.2 - - 3.8 - - 0 - - - - 990.6 - - 3.8 - - 3 - - - - 992.4 - - 3.8 - - 3.5 - - - - 991.3 - - 3.8 - - 3.4 - - - - 992.4 - - 3.8 - - 1.2 - - - - 991.9 - - 3.8 - - 0.1 - - - - 991.6 - - 4.2 - - 0 - - - - 991.7 - - 4.4 - - 0 - - - - 991.7 - - 4.9 - - 0 - - - - 991.7 - - 5.5 - - 0 - - - - 991.7 - - 4.8 - - 0 - - - - 991.7 - - 4.1 - - 0 - - - - 992 - - 4.1 - - 1.4 - - - - 992.9 - - 4.5 - - 3.8 - - - - 995.9 - - 3.9 - - 3.4 - - - - 997 - - 3.8 - - 3 - - - - 995.6 - - 3.8 - - 4.2 - - - - 996.7 - - 3.8 - - 3.8 - - - - 995.5 - - 3.8 - - 4.9 - - - - 994.2 - - 3.8 - - 3.7 - - - - 995.1 - - 3.8 - - 3.6 - - - - 993 - - 3.8 - - 5.7 - - - - 991.6 - - 3.8 - - 5.3 - - - - 986.7 - - 3.8 - - 5.4 - - - - 982.3 - - 3.8 - - 8 - - - - 981.5 - - 3.8 - - 4.4 - - - - 983.4 - - 3.8 - - 5.4 - - - - 984.4 - - 3.8 - - 4.3 - - - - 983.4 - - 3.8 - - 3.3 - - - - 984.5 - - 3.8 - - 4.2 - - - - 985.3 - - 3.8 - - 3 - - - - 984.4 - - 3.8 - - 1.7 - - - - 982.6 - - 3.8 - - 3.3 - - - - 980.2 - - 3.8 - - 5.1 - - - - 976.3 - - 3.8 - - 10.9 - - - - 970.9 - - 3.8 - - 7.6 - - - - 969.7 - - 3.8 - - 0.9 - - - - 969.7 - - 3.8 - - 0.1 - - - - 969.6 - - 3.9 - - 0.7 - - - - 969.7 - - 3.8 - - 0.1 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 968.5 - - 3.8 - - 0.5 - - - - 968.3 - - 3.8 - - 0.2 - - - - 968.7 - - 3.8 - - 0.1 - - - - 969 - - 3.8 - - 0 - - - - 966.7 - - 3.8 - - 1.7 - - - - 966.5 - - 3.8 - - 0.9 - - - - 966.7 - - 3.8 - - 0.4 - - - - 966.7 - - 4.4 - - 0 - - - - 965.3 - - 3.8 - - 2.1 - - - - 962.7 - - 3.8 - - 9 - - - - 962.5 - - 3.8 - - 8.8 - - - - 964.3 - - 3.8 - - 8.3 - - - - 963.8 - - 3.8 - - 7.5 - - - - 965.3 - - 3.8 - - 7 - - - - 964.6 - - 3.8 - - 7.3 - - - - 965.3 - - 3.8 - - 7.5 - - - - 965.9 - - 3.8 - - 7.4 - - - - 965.5 - - 3.8 - - 7.1 - - - - 966 - - 3.8 - - 3.8 - - - - 964.6 - - 3.8 - - 2 - - - - 964.3 - - 3.8 - - 0.1 - - - - 964.5 - - 3.8 - - 0 - - - - 964.5 - - 3.8 - - 0 - - - - 964.5 - - 3.8 - - 0 - - - - 964.5 - - 3.8 - - 0 - - - - 966.5 - - 3.8 - - 0.9 - - - - 966.6 - - 3.8 - - 0.1 - - - - 966 - - 3.8 - - 0.1 - - - - 966.1 - - 3.8 - - 0 - - - - 966 - - 3.8 - - 0 - - - - 965.6 - - 3.8 - - 0.8 - - - - 965.8 - - 3.8 - - 0.4 - - - - 967 - - 3.8 - - 2.5 - - - - 967.4 - - 3.9 - - 0.1 - - - - 967.3 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.2 - - 3.8 - - 1.4 - - - - 966.9 - - 3.8 - - 0.1 - - - - 967.1 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 966.9 - - 3.8 - - 0.4 - - - - 967.3 - - 3.8 - - 0.1 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.5 - - 3.8 - - 0.6 - - - - 968.5 - - 3.8 - - 0.1 - - - - 967.7 - - 3.8 - - 1.4 - - - - 968.5 - - 3.8 - - 1.4 - - - - 969.3 - - 3.8 - - 2.3 - - - - 970.3 - - 3.8 - - 1.7 - - - - 970.3 - - 3.8 - - 1.4 - - - - 970.7 - - 3.9 - - 1 - - - - 972.4 - - 3.8 - - 0.1 - - - - 973.6 - - 3.8 - - 0 - - - - 973.7 - - 3.8 - - 0 - - - - 974.2 - - 3.8 - - 1.2 - - - - 975.7 - - 3.8 - - 2.8 - - - - 977.4 - - 3.9 - - 3 - - - - 981 - - 3.8 - - 3.1 - - - - 984.9 - - 3.8 - - 2.9 - - - - 987 - - 3.8 - - 1.7 - - - - 988.2 - - 3.8 - - 3.4 - - - - 989.6 - - 3.8 - - 4 - - - - 991.6 - - 3.8 - - 3.3 - - - - 992.5 - - 3.8 - - 4.8 - - - - 995.7 - - 3.8 - - 3 - - - - 999.5 - - 3.8 - - 2.8 - - - - 1002.7 - - 3.8 - - 2.7 - - - - 1005.8 - - 3.8 - - 3.2 - - - - 1008.5 - - 3.8 - - 3.6 - - - - 1012.1 - - 3.8 - - 3.4 - - - - 1015.9 - - 3.8 - - 3.9 - - - - 1019.1 - - 3.8 - - 4.1 - - - - 1021.1 - - 3.8 - - 3.5 - - - - 1021.4 - - 3.8 - - 4.4 - - - - 1023.1 - - 3.8 - - 5.3 - - - - 1022.9 - - 3.8 - - 3.4 - - - - 1025.2 - - 3.8 - - 2.3 - - - - 1023.2 - - 3.8 - - 3.5 - - - - 1023 - - 3.8 - - 0.1 - - - - 1023.1 - - 3.8 - - 0.2 - - - - 1023.8 - - 3.8 - - 0.1 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.7 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1053.6 - - 10.3 - - 0.7 - - - - 1037.4 - - 4.3 - - 0.2 - - - - 1036.7 - - 3.8 - - 0.1 - - - - 1037.2 - - 3.3 - - 0.1 - - - - 1038 - - 3 - - 0.1 - - - - 1038.2 - - 3 - - 0.1 - - - - 1038.2 - - 3.4 - - 0.1 - - - - 1038.8 - - 3.8 - - 0 - - - - 1039.2 - - 3.8 - - 0.3 - - - - 1038.5 - - 3.8 - - 0.2 - - - - 1038.2 - - 6.9 - - - - - - 1091.2 - - 7.2 - - 0.2 - - - - 1088.4 - - 5 - - 0.1 - - - - 1095.6 - - 8.4 - - 0.2 - - - - 1065.6 - - 4.9 - - 2.9 - - - - 1071.3 - - 5 - - 0.1 - - - - 1070.4 - - 3.8 - - 0.1 - - - - 1070.7 - - 3.8 - - 0 - - - - 1070.7 - - 3.8 - - 0 - - - - 1070.7 - - 3.8 - - 0 - - - - 1070.7 - - 3.8 - - 0 - - - - 1070.6 - - 3.8 - - 0 - - - - 1070.6 - - 3.8 - - 0 - - - - 1070.6 - - 3.8 - - 0 - - - - 1070.4 - - 3.8 - - 0.6 - - - - 1070.8 - - 3.8 - - 0.1 - - - - 1070.7 - - 3.8 - - 0.1 - - - - 1070.7 - - 3.8 - - 0 - - - - - - 975.4 - - 14.5 - - 0.7 - - - - 974.7 - - 4 - - 0.5 - - - - 972.5 - - 3.8 - - 0.1 - - - - 971 - - 3.8 - - 0.9 - - - - 971.1 - - 3.1 - - 0.1 - - - - 971.5 - - 3 - - 0.1 - - - - 928 - - 8.1 - - 0.2 - - - - 928 - - 4.6 - - 0.3 - - - - 973.4 - - 3.7 - - 0.2 - - - - 971.6 - - 3 - - 0 - - - - 971.9 - - 3 - - 0 - - - - 971.9 - - 3.5 - - 0 - - - - 971.7 - - 3.8 - - 0 - - - - 971.4 - - 3.8 - - 0.4 - - - - 971.9 - - 3.8 - - 0.1 - - - - 971.4 - - 3.8 - - 0.4 - - - - 970.6 - - 4.1 - - 0.1 - - - - 971.3 - - 3 - - 0 - - - - 971.4 - - 3 - - 0 - - - - 971.4 - - 3 - - 0 - - @@ -4491,4 +369,4 @@ thin solid - \ No newline at end of file + diff --git a/spec/fixtures/files/gpx/gpx_track_multiple_tracks.gpx b/spec/fixtures/files/gpx/gpx_track_multiple_tracks.gpx index 757aaffd..38524c57 100644 --- a/spec/fixtures/files/gpx/gpx_track_multiple_tracks.gpx +++ b/spec/fixtures/files/gpx/gpx_track_multiple_tracks.gpx @@ -100,414 +100,6 @@ 6.3 - - 1056.1 - - 3.8 - - 7.1 - - - - 1053.5 - - 3.8 - - 5.9 - - - - 1054.6 - - 3.8 - - 2.8 - - - - 1053.8 - - 3.8 - - 4.5 - - - - 1053.2 - - 3.8 - - 5.4 - - - - 1054.2 - - 3.8 - - 5 - - - - 1053.7 - - 3.8 - - 6 - - - - 1053.9 - - 3.8 - - 5.5 - - - - 1054.9 - - 3.8 - - 3 - - - - 1056.4 - - 3.8 - - 4.3 - - - - 1057.2 - - 3.8 - - 2.9 - - - - 1057.5 - - 3.8 - - 3.9 - - - - 1059.1 - - 3.8 - - 6.6 - - - - 1062 - - 3.8 - - 5.9 - - - - 1064.6 - - 3.8 - - 3.9 - - - - 1065.8 - - 3.8 - - 5.3 - - - - 1067.7 - - 3.8 - - 5.4 - - - - 1068.6 - - 3.8 - - 4.1 - - - - 1068.2 - - 3.8 - - 0.9 - - - - 1069.5 - - 3.8 - - 1.5 - - - - 1069.4 - - 3.8 - - 0.1 - - - - 1069.4 - - 3.8 - - 0.1 - - - - 1069.4 - - 3.8 - - 0 - - - - 1068.9 - - 3.8 - - 2 - - - - 1069.9 - - 3.8 - - 4.9 - - - - 1070 - - 3.8 - - 3.4 - - - - 1070.8 - - 3.8 - - 2.5 - - - - 1072.1 - - 3.8 - - 2.3 - - - - 1072.3 - - 3.8 - - 3.2 - - - - 1073.2 - - 3.8 - - 2.2 - - - - 1072.8 - - 3.8 - - 2.6 - - - - 1073.9 - - 3.8 - - 2.2 - - - - 1075.8 - - 3.8 - - 3.7 - - - - 1078.4 - - 3.8 - - 5.2 - - - - 1079.7 - - 3.8 - - 5.7 - - - - 1084.2 - - 3.8 - - 6.3 - - - - 1085.7 - - 3.8 - - 3 - - - - 1086.8 - - 3.8 - - 0.8 - - - - 1086.1 - - 3.9 - - 0.6 - - - - 1085.8 - - 3.8 - - 0.9 - - - - 1086.8 - - 3.8 - - 0.6 - - - - 1086.3 - - 3.8 - - 2.5 - - - - 1088.1 - - 3.8 - - 1.6 - - - - 1087.7 - - 3.8 - - 0.6 - - - - 1087.3 - - 3.8 - - 1.9 - - - - 1086.6 - - 3.8 - - 0.3 - - - - 1086.4 - - 3.8 - - 0.2 - - - - 1085.9 - - 3.8 - - 0.5 - - - - 1085.5 - - 3.8 - - 0.6 - - - - 1084.2 - - 3.8 - - 1 - - - - 1085.8 - - 3.8 - - 0.1 - - @@ -606,2372 +198,6 @@ 3.5 - - 1086.4 - - 3.8 - - 3.6 - - - - 1085.4 - - 3.8 - - 0.7 - - - - 1085.8 - - 3.8 - - 0.1 - - - - 1085.6 - - 3.8 - - 0 - - - - 1085.6 - - 3.8 - - 0 - - - - 1085.6 - - 3.8 - - 0 - - - - 1084.1 - - 3.8 - - 5.8 - - - - 1081.1 - - 3.8 - - 6.4 - - - - 1081 - - 3.8 - - 6.2 - - - - 1079 - - 3.8 - - 5.3 - - - - 1076.6 - - 3.8 - - 4 - - - - 1074.3 - - 3.8 - - 7.5 - - - - 1071.8 - - 3.8 - - 7.3 - - - - 1069.4 - - 3.8 - - 6.8 - - - - 1066.8 - - 3.8 - - 5.7 - - - - 1063.1 - - 3.8 - - 6.1 - - - - 1061.3 - - 3.8 - - 7.4 - - - - 1058.7 - - 3.8 - - 6.6 - - - - 1056.3 - - 3.8 - - 6.7 - - - - 1052.6 - - 3.8 - - 6.5 - - - - 1054.2 - - 3.8 - - 5.6 - - - - 1054.6 - - 3.8 - - 1.6 - - - - 1054.1 - - 3.8 - - 1.7 - - - - 1054.1 - - 3.9 - - 0.1 - - - - 1053.9 - - 3.8 - - 0 - - - - 1054.7 - - 3.8 - - 3.1 - - - - 1051.4 - - 3.8 - - 3.1 - - - - 1048.3 - - 3.8 - - 2.2 - - - - 1046.8 - - 3.8 - - 1.1 - - - - 1044.3 - - 3.8 - - 1.7 - - - - 1043.5 - - 3.8 - - 0.2 - - - - 1043.1 - - 3.8 - - 0.1 - - - - 1043 - - 3.8 - - 0.3 - - - - 1043 - - 3.8 - - 0.1 - - - - 1043.4 - - 3.8 - - 0.1 - - - - 1043.2 - - 3.8 - - 0.3 - - - - 1042.2 - - 3.8 - - 0.1 - - - - 1041 - - 3.8 - - 1.7 - - - - 1039.8 - - 3.8 - - 1.9 - - - - 1038.7 - - 3.8 - - 0.3 - - - - 1037 - - 3.8 - - 0.3 - - - - 1036.9 - - 3.8 - - 0 - - - - 1036.9 - - 3.8 - - 0 - - - - 1035.5 - - 3.8 - - 3.7 - - - - 1032.2 - - 3.8 - - 3.3 - - - - 1029.1 - - 3.8 - - 3.3 - - - - 1025.4 - - 3.8 - - 2.4 - - - - 1022.2 - - 3.8 - - 6 - - - - 1019.4 - - 3.8 - - 4.6 - - - - 1017 - - 3.8 - - 4.6 - - - - 1014.6 - - 3.8 - - 1.8 - - - - 1011.3 - - 3.8 - - 1.4 - - - - 1010.4 - - 3.8 - - 0.8 - - - - 1006.2 - - 3.8 - - 1 - - - - 1007.5 - - 3.8 - - 0.2 - - - - 1007.7 - - 3.8 - - 0.1 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.7 - - 3.8 - - 0 - - - - 1007.5 - - 3.8 - - 0.8 - - - - 1007.5 - - 3.8 - - 0.2 - - - - 1005.9 - - 3.8 - - 0.2 - - - - 1006.2 - - 3.9 - - 0.6 - - - - 1005.9 - - 3.8 - - 0.6 - - - - 1007.4 - - 3.8 - - 0.4 - - - - 1007.1 - - 3.8 - - 0.5 - - - - 1002.9 - - 3.8 - - 0.6 - - - - 1002.7 - - 3.8 - - 1.1 - - - - 1002.6 - - 3.8 - - 0.3 - - - - 1004.1 - - 3.8 - - 0.1 - - - - 1004.4 - - 3.8 - - 0 - - - - 1004.4 - - 3.8 - - 0 - - - - 1002.6 - - 3.8 - - 0.9 - - - - 1002.3 - - 3.8 - - 1.4 - - - - 1001.3 - - 3.8 - - 0.7 - - - - 1000.8 - - 3.8 - - 0.7 - - - - 997.5 - - 3.8 - - 1.5 - - - - 997.2 - - 3.8 - - 0.2 - - - - 996.2 - - 3.8 - - 1.8 - - - - 993.6 - - 3.8 - - 1.6 - - - - 993.9 - - 3.8 - - 0.4 - - - - 990.8 - - 3.8 - - 4.4 - - - - 990.6 - - 3.8 - - 0.1 - - - - 990.4 - - 3.8 - - 0.6 - - - - 989.6 - - 3.8 - - 0.2 - - - - 989.8 - - 3.8 - - 0.1 - - - - 989.7 - - 3.8 - - 0 - - - - 989.6 - - 3.8 - - 0.6 - - - - 989.3 - - 3.8 - - 0.4 - - - - 989.9 - - 3.8 - - 0.2 - - - - 990.2 - - 3.8 - - 0 - - - - 990.2 - - 3.8 - - 0 - - - - 990.6 - - 3.8 - - 3 - - - - 992.4 - - 3.8 - - 3.5 - - - - 991.3 - - 3.8 - - 3.4 - - - - 992.4 - - 3.8 - - 1.2 - - - - 991.9 - - 3.8 - - 0.1 - - - - 991.6 - - 4.2 - - 0 - - - - 991.7 - - 4.4 - - 0 - - - - 991.7 - - 4.9 - - 0 - - - - 991.7 - - 5.5 - - 0 - - - - 991.7 - - 4.8 - - 0 - - - - 991.7 - - 4.1 - - 0 - - - - 992 - - 4.1 - - 1.4 - - - - 992.9 - - 4.5 - - 3.8 - - - - 995.9 - - 3.9 - - 3.4 - - - - 997 - - 3.8 - - 3 - - - - 995.6 - - 3.8 - - 4.2 - - - - 996.7 - - 3.8 - - 3.8 - - - - 995.5 - - 3.8 - - 4.9 - - - - 994.2 - - 3.8 - - 3.7 - - - - 995.1 - - 3.8 - - 3.6 - - - - 993 - - 3.8 - - 5.7 - - - - 991.6 - - 3.8 - - 5.3 - - - - 986.7 - - 3.8 - - 5.4 - - - - 982.3 - - 3.8 - - 8 - - - - 981.5 - - 3.8 - - 4.4 - - - - 983.4 - - 3.8 - - 5.4 - - - - 984.4 - - 3.8 - - 4.3 - - - - 983.4 - - 3.8 - - 3.3 - - - - 984.5 - - 3.8 - - 4.2 - - - - 985.3 - - 3.8 - - 3 - - - - 984.4 - - 3.8 - - 1.7 - - - - 982.6 - - 3.8 - - 3.3 - - - - 980.2 - - 3.8 - - 5.1 - - - - 976.3 - - 3.8 - - 10.9 - - - - 970.9 - - 3.8 - - 7.6 - - - - 969.7 - - 3.8 - - 0.9 - - - - 969.7 - - 3.8 - - 0.1 - - - - 969.6 - - 3.9 - - 0.7 - - - - 969.7 - - 3.8 - - 0.1 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 969.1 - - 3.8 - - 0 - - - - 968.3 - - 3.8 - - 0.2 - - - - 968.7 - - 3.8 - - 0.1 - - - - 969 - - 3.8 - - 0 - - - - 966.7 - - 3.8 - - 1.7 - - - - 966.5 - - 3.8 - - 0.9 - - - - 966.7 - - 3.8 - - 0.4 - - - - 966.7 - - 4.4 - - 0 - - - - 965.3 - - 3.8 - - 2.1 - - - - 962.7 - - 3.8 - - 9 - - - - 962.5 - - 3.8 - - 8.8 - - - - 964.3 - - 3.8 - - 8.3 - - - - 963.8 - - 3.8 - - 7.5 - - - - 965.3 - - 3.8 - - 7 - - - - 964.6 - - 3.8 - - 7.3 - - - - 965.3 - - 3.8 - - 7.5 - - - - 965.9 - - 3.8 - - 7.4 - - - - 965.5 - - 3.8 - - 7.1 - - - - 966 - - 3.8 - - 3.8 - - - - 964.6 - - 3.8 - - 2 - - - - 964.3 - - 3.8 - - 0.1 - - - - 964.5 - - 3.8 - - 0 - - - - 964.5 - - 3.8 - - 0 - - - - 964.5 - - 3.8 - - 0 - - - - 964.5 - - 3.8 - - 0 - - - - 966.5 - - 3.8 - - 0.9 - - - - 966.6 - - 3.8 - - 0.1 - - - - 966 - - 3.8 - - 0.1 - - - - 966.1 - - 3.8 - - 0 - - - - 966 - - 3.8 - - 0 - - - - 965.6 - - 3.8 - - 0.8 - - - - 965.8 - - 3.8 - - 0.4 - - - - 967 - - 3.8 - - 2.5 - - - - 967.4 - - 3.9 - - 0.1 - - - - 967.3 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.9 - - 3.8 - - 0 - - - - 967.2 - - 3.8 - - 1.4 - - - - 966.9 - - 3.8 - - 0.1 - - - - 967.1 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 967.1 - - 3.8 - - 0 - - - - 966.9 - - 3.8 - - 0.4 - - - - 967.3 - - 3.8 - - 0.1 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.8 - - 3.8 - - 0 - - - - 967.5 - - 3.8 - - 0.6 - - - - 968.5 - - 3.8 - - 0.1 - - - - 967.7 - - 3.8 - - 1.4 - - - - 968.5 - - 3.8 - - 1.4 - - - - 969.3 - - 3.8 - - 2.3 - - - - 970.3 - - 3.8 - - 1.7 - - - - 970.3 - - 3.8 - - 1.4 - - - - 970.7 - - 3.9 - - 1 - - - - 972.4 - - 3.8 - - 0.1 - - - - 973.6 - - 3.8 - - 0 - - - - 973.7 - - 3.8 - - 0 - - - - 974.2 - - 3.8 - - 1.2 - - - - 975.7 - - 3.8 - - 2.8 - - - - 977.4 - - 3.9 - - 3 - - - - 981 - - 3.8 - - 3.1 - - - - 984.9 - - 3.8 - - 2.9 - - - - 987 - - 3.8 - - 1.7 - - - - 988.2 - - 3.8 - - 3.4 - - - - 989.6 - - 3.8 - - 4 - - - - 991.6 - - 3.8 - - 3.3 - - - - 992.5 - - 3.8 - - 4.8 - - - - 995.7 - - 3.8 - - 3 - - - - 999.5 - - 3.8 - - 2.8 - - - - 1002.7 - - 3.8 - - 2.7 - - - - 1005.8 - - 3.8 - - 3.2 - - - - 1008.5 - - 3.8 - - 3.6 - - - - 1012.1 - - 3.8 - - 3.4 - - - - 1015.9 - - 3.8 - - 3.9 - - - - 1019.1 - - 3.8 - - 4.1 - - - - 1021.1 - - 3.8 - - 3.5 - - - - 1021.4 - - 3.8 - - 4.4 - - - - 1023.1 - - 3.8 - - 5.3 - - - - 1022.9 - - 3.8 - - 3.4 - - - - 1025.2 - - 3.8 - - 2.3 - - - - 1023.2 - - 3.8 - - 3.5 - - - - 1023 - - 3.8 - - 0.1 - - - - 1023.1 - - 3.8 - - 0.2 - - - - 1023.8 - - 3.8 - - 0.1 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.7 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1023.6 - - 3.8 - - 0 - - - - 1053.6 - - 10.3 - - 0.7 - - - - 1037.4 - - 4.3 - - 0.2 - - - - 1036.7 - - 3.8 - - 0.1 - - - - 1037.2 - - 3.3 - - 0.1 - - - - 1038 - - 3 - - 0.1 - - - - 1038.2 - - 3 - - 0.1 - - - - 1038.2 - - 3.4 - - 0.1 - - - - 1038.8 - - 3.8 - - 0 - - - - 1039.2 - - 3.8 - - 0.3 - - - - 1038.5 - - 3.8 - - 0.2 - - - - 1038.2 - - 6.9 - - @@ -3064,216 +290,6 @@ 0 - - 1070.6 - - 3.8 - - 0 - - - - 1070.6 - - 3.8 - - 0 - - - - 1070.4 - - 3.8 - - 0.6 - - - - 1070.8 - - 3.8 - - 0.1 - - - - 1070.7 - - 3.8 - - 0.1 - - - - 1070.7 - - 3.8 - - 0 - - - - - - 975.4 - - 14.5 - - 0.7 - - - - 974.7 - - 4 - - 0.5 - - - - 972.5 - - 3.8 - - 0.1 - - - - 971 - - 3.8 - - 0.9 - - - - 971.1 - - 3.1 - - 0.1 - - - - 971.5 - - 3 - - 0.1 - - - - 928 - - 8.1 - - 0.2 - - - - 928 - - 4.6 - - 0.3 - - - - 973.4 - - 3.7 - - 0.2 - - - - 971.6 - - 3 - - 0 - - - - 971.9 - - 3 - - 0 - - - - 971.9 - - 3.5 - - 0 - - - - 971.7 - - 3.8 - - 0 - - - - 971.4 - - 3.8 - - 0.4 - - - - 971.9 - - 3.8 - - 0.1 - - - - 971.4 - - 3.8 - - 0.4 - - - - 970.6 - - 4.1 - - 0.1 - - - - 971.3 - - 3 - - 0 - - - - 971.4 - - 3 - - 0 - - - - 971.4 - - 3 - - 0 - - @@ -3288,4 +304,4 @@ thin solid - \ No newline at end of file + diff --git a/spec/fixtures/files/gpx/gpx_track_single_segment.gpx b/spec/fixtures/files/gpx/gpx_track_single_segment.gpx index c7447af0..44125aa7 100644 --- a/spec/fixtures/files/gpx/gpx_track_single_segment.gpx +++ b/spec/fixtures/files/gpx/gpx_track_single_segment.gpx @@ -54,1186 +54,6 @@ 798.9 - - 797.19 - - - - 795.8 - - - - 794.31 - - - - 793.25 - - - - 792.19 - - - - 791.44 - - - - 791.24 - - - - 791.47 - - - - 792.04 - - - - 792.18 - - - - 793.94 - - - - 795.29 - - - - 796.89 - - - - 798.7 - - - - 801.44 - - - - 803.97 - - - - 806.6 - - - - 809.27 - - - - 811.96 - - - - 814.62 - - - - 817.54 - - - - 820.18 - - - - 822.76 - - - - 825.25 - - - - 827.89 - - - - 830.82 - - - - 833.17 - - - - 835.42 - - - - 837.9 - - - - 839.89 - - - - 841.98 - - - - 844.17 - - - - 846.01 - - - - 847.32 - - - - 848.51 - - - - 849.54 - - - - 850.3 - - - - 850.74 - - - - 851.11 - - - - 851.31 - - - - 851.37 - - - - 851.36 - - - - 851.21 - - - - 851.04 - - - - 850.86 - - - - 850.41 - - - - 849.94 - - - - 849.54 - - - - 849.08 - - - - 848.67 - - - - 848.36 - - - - 848.08 - - - - 847.87 - - - - 847.77 - - - - 847.74 - - - - 847.75 - - - - 847.81 - - - - 847.96 - - - - 848.17 - - - - 848.37 - - - - 848.68 - - - - 849.01 - - - - 849.24 - - - - 849.47 - - - - 849.7 - - - - 849.88 - - - - 850.1 - - - - 850.25 - - - - 850.38 - - - - 850.47 - - - - 850.46 - - - - 850.35 - - - - 850.35 - - - - 850.02 - - - - 849.6 - - - - 849.05 - - - - 848.37 - - - - 847.54 - - - - 846.57 - - - - 845.55 - - - - 844.29 - - - - 842.85 - - - - 841.43 - - - - 839.98 - - - - 838.63 - - - - 837.18 - - - - 835.48 - - - - 833.92 - - - - 832.43 - - - - 831.06 - - - - 829.84 - - - - 829.04 - - - - 828.42 - - - - 828.15 - - - - 828.11 - - - - 828.51 - - - - 829.55 - - - - 830.31 - - - - 831.12 - - - - 831.93 - - - - 832.91 - - - - 833.85 - - - - 834.91 - - - - 836.07 - - - - 837.2 - - - - 838.38 - - - - 839.56 - - - - 840.58 - - - - 841.58 - - - - 842.46 - - - - 843.23 - - - - 843.46 - - - - 843.41 - - - - 842.64 - - - - 841.84 - - - - 840.81 - - - - 839.56 - - - - 837.86 - - - - 836.03 - - - - 833.91 - - - - 831.55 - - - - 828.71 - - - - 825.47 - - - - 820.96 - - - - 817.85 - - - - 814.71 - - - - 811.52 - - - - 808.25 - - - - 805.03 - - - - 801.68 - - - - 798.27 - - - - 794.91 - - - - 791.73 - - - - 788.61 - - - - 785.48 - - - - 782.4 - - - - 779.42 - - - - 776.47 - - - - 773.67 - - - - 770.99 - - - - 768.4 - - - - 765.66 - - - - 763.1 - - - - 760.26 - - - - 757.88 - - - - 755.75 - - - - 753.7 - - - - 751.75 - - - - 749.94 - - - - 748.17 - - - - 746.34 - - - - 744.47 - - - - 743.18 - - - - 742.0 - - - - 741.01 - - - - 740.17 - - - - 739.53 - - - - 738.88 - - - - 738.42 - - - - 738.16 - - - - 738.01 - - - - 738.01 - - - - 738.11 - - - - 738.36 - - - - 738.8 - - - - 739.13 - - - - 739.78 - - - - 740.12 - - - - 740.55 - - - - 740.93 - - - - 741.31 - - - - 741.6 - - - - 741.82 - - - - 741.89 - - - - 741.94 - - - - 741.89 - - - - 742.0 - - - - 742.05 - - - - 742.17 - - - - 742.28 - - - - 742.49 - - - - 742.74 - - - - 742.86 - - - - 743.34 - - - - 744.01 - - - - 744.96 - - - - 746.14 - - - - 747.41 - - - - 748.68 - - - - 750.03 - - - - 751.57 - - - - 753.47 - - - - 755.4 - - - - 757.49 - - - - 759.68 - - - - 762.09 - - - - 764.56 - - - - 767.4 - - - - 770.3 - - - - 773.45 - - - - 776.83 - - - - 780.51 - - - - 783.74 - - - - 786.94 - - - - 790.76 - - - - 794.06 - - - - 797.36 - - - - 800.75 - - - - 804.12 - - - - 807.53 - - - - 811.02 - - - - 814.61 - - - - 818.13 - - - - 821.6 - - - - 825.29 - - - - 828.89 - - - - 832.37 - - - - 836.28 - - - - 839.49 - - - - 842.19 - - - - 844.74 - - - - 847.21 - - - - 849.34 - - - - 851.3 - - - - 852.93 - - - - 854.35 - - - - 855.69 - - - - 856.86 - - - - 857.72 - - - - 858.43 - - - - 858.78 - - - - 859.01 - - - - 859.0 - - - - 858.97 - - - - 859.21 - - - - 859.45 - - - - 859.73 - - - - 860.06 - - - - 860.45 - - - - 861.08 - - - - 861.61 - - - - 862.29 - - - - 863.0 - - - - 863.9 - - - - 864.96 - - - - 866.07 - - - - 867.3 - - - - 869.0 - - - - 870.45 - - - - 871.97 - - - - 873.37 - - - - 874.8 - - - - 876.17 - - - - 877.6 - - - - 879.15 - - - - 880.87 - - - - 882.54 - - - - 884.28 - - - - 886.01 - - - - 887.84 - - - - 889.62 - - - - 891.29 - - - - 892.83 - - - - 893.87 - - - - 894.78 - - - - 895.66 - - - - 896.51 - - - - 896.83 - - - - 896.95 - - - - 896.98 - - - - 896.67 - - - - 896.92 - - - - 897.13 - - - - 897.08 - - - - 897.65 - - - - 898.62 - - - - 899.59 - - - - 900.3 - - - - 901.06 - - - - 901.98 - - - - 902.94 - - - - 904.14 - - - - 905.06 - - - - 905.5 - - - - 905.8 - - - - 905.47 - - - - 905.91 - - - - 906.01 - - - - 905.66 - - - - 904.85 - - - - 904.4 - - - - 903.49 - - - - 903.02 - - - - 901.8 - - - - 901.42 - - diff --git a/spec/services/gpx/track_parser_spec.rb b/spec/services/gpx/track_parser_spec.rb index c5980c91..02fa3110 100644 --- a/spec/services/gpx/track_parser_spec.rb +++ b/spec/services/gpx/track_parser_spec.rb @@ -13,11 +13,11 @@ RSpec.describe Gpx::TrackParser do context 'when file has a single segment' do it 'creates points' do - expect { parser }.to change { Point.count }.by(301) + expect { parser }.to change { Point.count }.by(10) end it 'broadcasts importing progress' do - expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(301).times + expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(10).times parser end @@ -27,11 +27,11 @@ RSpec.describe Gpx::TrackParser do let(:file_path) { Rails.root.join('spec/fixtures/files/gpx/gpx_track_multiple_segments.gpx') } it 'creates points' do - expect { parser }.to change { Point.count }.by(558) + expect { parser }.to change { Point.count }.by(43) end it 'broadcasts importing progress' do - expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(558).times + expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(43).times parser end @@ -41,11 +41,11 @@ RSpec.describe Gpx::TrackParser do let(:file_path) { Rails.root.join('spec/fixtures/files/gpx/gpx_track_multiple_tracks.gpx') } it 'creates points' do - expect { parser }.to change { Point.count }.by(407) + expect { parser }.to change { Point.count }.by(34) end it 'broadcasts importing progress' do - expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(407).times + expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).exactly(34).times parser end From da2ab3b62bbe1ca7bc04e246dd30d4732272adad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:19:59 +0000 Subject: [PATCH 137/157] Bump database_consistency from 2.0.3 to 2.0.4 Bumps [database_consistency](https://github.com/djezzzl/database_consistency) from 2.0.3 to 2.0.4. - [Changelog](https://github.com/djezzzl/database_consistency/blob/master/CHANGELOG.md) - [Commits](https://github.com/djezzzl/database_consistency/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: database_consistency dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e0398b56..8fa62291 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,7 +118,7 @@ GEM data_migrate (11.2.0) activerecord (>= 6.1) railties (>= 6.1) - database_consistency (2.0.3) + database_consistency (2.0.4) activerecord (>= 3.2) date (3.4.1) debug (1.10.0) From 509779feafd7a292d78beeb560f590882057748b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:20:21 +0000 Subject: [PATCH 138/157] Bump webmock from 3.24.0 to 3.25.0 Bumps [webmock](https://github.com/bblimke/webmock) from 3.24.0 to 3.25.0. - [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md) - [Commits](https://github.com/bblimke/webmock/compare/v3.24.0...v3.25.0) --- updated-dependencies: - dependency-name: webmock dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e0398b56..fff02c54 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -160,7 +160,7 @@ GEM rake groupdate (6.5.1) activesupport (>= 7) - hashdiff (1.1.1) + hashdiff (1.1.2) httparty (0.22.0) csv mini_mime (>= 1.0.0) @@ -326,7 +326,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.8) + rexml (3.4.0) rgeo (3.0.1) rgeo-activerecord (8.0.0) activerecord (>= 7.0) @@ -442,7 +442,7 @@ GEM useragent (0.16.11) warden (1.2.9) rack (>= 2.0.9) - webmock (3.24.0) + webmock (3.25.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) From 769c85bd15cd1665b45c9296e8883a30aab06657 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:20:33 +0000 Subject: [PATCH 139/157] Bump rspec-rails from 7.1.0 to 7.1.1 Bumps [rspec-rails](https://github.com/rspec/rspec-rails) from 7.1.0 to 7.1.1. - [Changelog](https://github.com/rspec/rspec-rails/blob/main/Changelog.md) - [Commits](https://github.com/rspec/rspec-rails/compare/v7.1.0...v7.1.1) --- updated-dependencies: - dependency-name: rspec-rails dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e0398b56..8fff6fa8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,18 +224,18 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.18.1) + nokogiri (1.18.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.1-aarch64-linux-gnu) + nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm-linux-gnu) + nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm64-darwin) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-darwin) + nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-gnu) + nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) oj (3.16.9) bigdecimal (>= 3.0) @@ -273,7 +273,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.8) + rack (3.1.9) rack-session (2.1.0) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -312,7 +312,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.11.0) + rdoc (6.12.0) psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -331,7 +331,7 @@ GEM rgeo-activerecord (8.0.0) activerecord (>= 7.0) rgeo (>= 3.0) - rspec-core (3.13.2) + rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) @@ -339,7 +339,7 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.1.0) + rspec-rails (7.1.1) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -347,7 +347,7 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.1) + rspec-support (3.13.2) rswag-api (2.16.0) activesupport (>= 5.2, < 8.1) railties (>= 5.2, < 8.1) From 0b362168c98aca2f1bd6265fb0cffad6fdd476c8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 10 Feb 2025 20:37:20 +0100 Subject: [PATCH 140/157] 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 141/157] 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 01fd9f6e35ff098ff4c3c12937f28e2a4f717034 Mon Sep 17 00:00:00 2001 From: Christian Nikel Date: Tue, 11 Feb 2025 00:12:01 +0000 Subject: [PATCH 142/157] Fix fog gets displaced when dragging map Also recalculates the size of the fog when resizing the browser window. Closes #774 --- app/javascript/maps/fog_of_war.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index 8e910274..bc3acbb1 100644 --- a/app/javascript/maps/fog_of_war.js +++ b/app/javascript/maps/fog_of_war.js @@ -85,15 +85,12 @@ export function createFogOverlay() { onAdd: (map) => { initializeFogCanvas(map); - // Add drag event handlers to update fog during marker movement - map.on('drag', () => { - const fog = document.getElementById('fog'); - if (fog) { - // Update fog canvas position to match map position - const mapPos = map.getContainer().getBoundingClientRect(); - fog.style.left = `${mapPos.left}px`; - fog.style.top = `${mapPos.top}px`; - } + // Add resize event handlers to update fog size + map.on('resize', () => { + // Set canvas size to match map container + const mapSize = map.getSize(); + fog.width = mapSize.x; + fog.height = mapSize.y; }); }, onRemove: (map) => { @@ -102,7 +99,7 @@ export function createFogOverlay() { fog.remove(); } // Clean up event listener - map.off('drag'); + map.off('resize'); } }); } From 1580fb8ade978a582ccb2a7204cf5e3a54e07251 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 11 Feb 2025 20:45:36 +0100 Subject: [PATCH 143/157] 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 144/157] 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 145/157] 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 146/157] 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 147/157] 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 148/157] 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 From 93dbde7e79313c6cb53a74d1ab4cd281410424d9 Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Wed, 12 Feb 2025 22:18:22 +0100 Subject: [PATCH 149/157] Revert "fix: set dbname in psql entrypoint commands" --- docker/sidekiq-entrypoint.sh | 2 +- docker/web-entrypoint.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/sidekiq-entrypoint.sh b/docker/sidekiq-entrypoint.sh index cc4e20cd..1083891b 100644 --- a/docker/sidekiq-entrypoint.sh +++ b/docker/sidekiq-entrypoint.sh @@ -24,7 +24,7 @@ fi # Wait for the database to become available echo "⏳ Waiting for database to be ready..." -until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c '\q'; do +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do >&2 echo "Postgres is unavailable - retrying..." sleep 2 done diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh index 5c82d1b0..230f91cc 100644 --- a/docker/web-entrypoint.sh +++ b/docker/web-entrypoint.sh @@ -29,14 +29,14 @@ rm -f $APP_PATH/tmp/pids/server.pid # Wait for the database to become available echo "⏳ Waiting for database to be ready..." -until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c '\q'; do +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do >&2 echo "Postgres is unavailable - retrying..." sleep 2 done echo "✅ PostgreSQL is ready!" # Create database if it doesn't exist -if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then +if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then echo "Creating database $DATABASE_NAME..." bundle exec rails db:create fi From dc7bd841fd4494c439eeaab72731f943a8001000 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 12 Feb 2025 22:56:11 +0100 Subject: [PATCH 150/157] Introduce matrix for parallel docker image builds --- .github/workflows/build_and_push.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 49580227..c991e46f 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -11,8 +11,11 @@ on: types: [created] jobs: - build-and-push-docker: + build-and-push: runs-on: ubuntu-22.04 + strategy: + matrix: + platform: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] steps: - name: Checkout code uses: actions/checkout@v4 @@ -51,10 +54,7 @@ jobs: # Add :rc tag for pre-releases if [ "${{ github.event.release.prerelease }}" = "true" ]; then TAGS="${TAGS},freikin/dawarich:rc" - fi - - # Add :latest tag only if release is not a pre-release - if [ "${{ github.event.release.prerelease }}" != "true" ]; then + else TAGS="${TAGS},freikin/dawarich:latest" fi @@ -67,6 +67,6 @@ jobs: file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 + platforms: ${{ matrix.platform }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From a52a3df4b9e26c6508d0e03dc1e1a2b806cfa3d3 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 13 Feb 2025 19:09:06 +0100 Subject: [PATCH 151/157] Update build and push workflow --- .github/workflows/build_and_push.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index c991e46f..80e5dab4 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -70,3 +70,5 @@ jobs: platforms: ${{ matrix.platform }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache + provenance: false + outputs: type=registry,push=true From 41243dda757f03789e2bb8cdc604c4910489a25b Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Thu, 13 Feb 2025 20:12:14 +0100 Subject: [PATCH 152/157] Revert "Feature/parallel docker image builds" --- .github/workflows/build_and_push.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 80e5dab4..a0092f9f 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -11,11 +11,8 @@ on: types: [created] jobs: - build-and-push: + build-and-push-docker: runs-on: ubuntu-22.04 - strategy: - matrix: - platform: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] steps: - name: Checkout code uses: actions/checkout@v4 @@ -54,7 +51,10 @@ jobs: # Add :rc tag for pre-releases if [ "${{ github.event.release.prerelease }}" = "true" ]; then TAGS="${TAGS},freikin/dawarich:rc" - else + fi + + # Add :latest tag only if release is not a pre-release + if [ "${{ github.event.release.prerelease }}" != "true" ]; then TAGS="${TAGS},freikin/dawarich:latest" fi @@ -67,7 +67,7 @@ jobs: file: ./docker/Dockerfile.dev push: true tags: ${{ steps.docker_meta.outputs.tags }} - platforms: ${{ matrix.platform }} + platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache provenance: false From 46fbb4a1b16e447670db2a9a4210ec7710052706 Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Thu, 13 Feb 2025 20:27:51 +0100 Subject: [PATCH 153/157] Revert "Add radius param of 10" --- app/services/reverse_geocoding/places/fetch_data.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 12186c9f..9eec9de4 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -96,7 +96,7 @@ class ReverseGeocoding::Places::FetchData end def reverse_geocoded_places - data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true, radius: 10) + data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true) data.reject do |place| place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) || From 3d01bead20a14af32dd55b31e0e3e02b3c0c923a Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Thu, 13 Feb 2025 20:28:09 +0100 Subject: [PATCH 154/157] Revert "Revert "Add radius param of 10"" --- app/services/reverse_geocoding/places/fetch_data.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 9eec9de4..12186c9f 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -96,7 +96,7 @@ class ReverseGeocoding::Places::FetchData end def reverse_geocoded_places - data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true) + data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true, radius: 10) data.reject do |place| place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) || From 51e589e17ffc834d6a22b10a836d21896e4b032a Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 13 Feb 2025 21:04:29 +0100 Subject: [PATCH 155/157] Implement map tiles usage tracking and chart in user settings. --- CHANGELOG.md | 1 + .../api/v1/maps/tile_usage_controller.rb | 2 +- app/controllers/settings/maps_controller.rb | 7 +++ app/javascript/controllers/maps_controller.js | 5 +- app/javascript/maps/tile_monitor.js | 10 +-- app/services/maps/tile_usage/track.rb | 23 ++++++- .../reverse_geocoding/places/fetch_data.rb | 8 ++- app/views/map/index.html.erb | 1 - app/views/settings/maps/index.html.erb | 63 +++++++++++-------- config/initializers/reddis.rb | 7 --- spec/requests/api/v1/maps/tile_usage_spec.rb | 7 +-- spec/services/maps/tile_usage/track_spec.rb | 17 ++++- 12 files changed, 94 insertions(+), 57 deletions(-) delete mode 100644 config/initializers/reddis.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb1084b..c121fd47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ 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 +- In the user map settings, you can now see a chart of map tiles usage. - 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: ``` diff --git a/app/controllers/api/v1/maps/tile_usage_controller.rb b/app/controllers/api/v1/maps/tile_usage_controller.rb index 43f8f070..c22778e7 100644 --- a/app/controllers/api/v1/maps/tile_usage_controller.rb +++ b/app/controllers/api/v1/maps/tile_usage_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Maps::TileUsageController < ApiController def create - Maps::TileUsage::Track.new(tile_usage_params[:count].to_i).call + Maps::TileUsage::Track.new(current_api_user.id, tile_usage_params[:count].to_i).call head :ok end diff --git a/app/controllers/settings/maps_controller.rb b/app/controllers/settings/maps_controller.rb index 59beb04d..58e2fef6 100644 --- a/app/controllers/settings/maps_controller.rb +++ b/app/controllers/settings/maps_controller.rb @@ -5,6 +5,13 @@ class Settings::MapsController < ApplicationController def index @maps = current_user.safe_settings.maps + + @tile_usage = 7.days.ago.to_date.upto(Time.zone.today).map do |date| + [ + date.to_s, + Rails.cache.read("dawarich_map_tiles_usage:#{current_user.id}:#{date}") || 0 + ] + end end def update diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 53b39c20..d2f59dbb 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -34,9 +34,6 @@ import { TileMonitor } from "../maps/tile_monitor"; export default class extends Controller { static targets = ["container"]; - static values = { - monitoringEnabled: Boolean - } settingsButtonAdded = false; layerControl = null; @@ -249,7 +246,7 @@ export default class extends Controller { } // Initialize tile monitor - this.tileMonitor = new TileMonitor(this.monitoringEnabledValue, this.apiKey); + this.tileMonitor = new TileMonitor(this.apiKey); // Add tile load event handlers to each base layer Object.entries(this.baseMaps()).forEach(([name, layer]) => { diff --git a/app/javascript/maps/tile_monitor.js b/app/javascript/maps/tile_monitor.js index 3a4ff36e..0a1edc60 100644 --- a/app/javascript/maps/tile_monitor.js +++ b/app/javascript/maps/tile_monitor.js @@ -1,15 +1,11 @@ export class TileMonitor { - constructor(monitoringEnabled, apiKey) { - this.monitoringEnabled = monitoringEnabled; + constructor(apiKey) { 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); @@ -29,13 +25,11 @@ export class TileMonitor { } 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; + if (this.tileQueue === 0) return; const currentCount = this.tileQueue; console.log('Sending tile usage batch:', currentCount); diff --git a/app/services/maps/tile_usage/track.rb b/app/services/maps/tile_usage/track.rb index 0affd754..a2ec819d 100644 --- a/app/services/maps/tile_usage/track.rb +++ b/app/services/maps/tile_usage/track.rb @@ -1,11 +1,23 @@ # frozen_string_literal: true class Maps::TileUsage::Track - def initialize(count = 1) + def initialize(user_id, count = 1) + @user_id = user_id @count = count end def call + report_to_prometheus + report_to_cache + rescue StandardError => e + Rails.logger.error("Failed to send tile usage metric: #{e.message}") + end + + private + + def report_to_prometheus + return unless DawarichSettings.prometheus_exporter_enabled? + metric_data = { type: 'counter', name: 'dawarich_map_tiles_usage', @@ -13,7 +25,12 @@ class Maps::TileUsage::Track } PrometheusExporter::Client.default.send_json(metric_data) - rescue StandardError => e - Rails.logger.error("Failed to send tile usage metric: #{e.message}") + end + + def report_to_cache + today_key = "dawarich_map_tiles_usage:#{@user_id}:#{Time.zone.today}" + + current_value = (Rails.cache.read(today_key) || 0).to_i + Rails.cache.write(today_key, current_value + @count, expires_in: 7.days) end end diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 12186c9f..9b691d36 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -96,7 +96,13 @@ class ReverseGeocoding::Places::FetchData end def reverse_geocoded_places - data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true, radius: 10) + data = Geocoder.search( + [place.latitude, place.longitude], + limit: 10, + distance_sort: true, + radius: 1, + units: DISTANCE_UNITS + ) data.reject do |place| place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) || diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 511f12a7..9fa4a0fe 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -53,7 +53,6 @@ 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 2e50950d..e80d875a 100644 --- a/app/views/settings/maps/index.html.erb +++ b/app/views/settings/maps/index.html.erb @@ -22,35 +22,46 @@ Please remember, that using a custom tile URL may result in extra costs. Check your map tile provider's terms of service for more information.
-
- <%= 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" %> -
+
+
+ <%= form_for :maps, + url: settings_maps_path, + method: :patch, + autocomplete: "off", + data: { turbo_method: :patch, turbo: false } 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.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 %> + <%= f.submit 'Save', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %> + <% end %> -
+

Tile usage

+ + <%= line_chart( + @tile_usage, + height: '200px', + xtitle: 'Days', + ytitle: 'Tiles', + suffix: ' tiles loaded' + ) %> +
+ +
"Bearer #{user.api_key}" } - expect(Maps::TileUsage::Track).to have_received(:new).with(tile_count) + expect(Maps::TileUsage::Track).to have_received(:new).with(user.id, tile_count) expect(track_service).to have_received(:call) expect(response).to have_http_status(:ok) end diff --git a/spec/services/maps/tile_usage/track_spec.rb b/spec/services/maps/tile_usage/track_spec.rb index 896810eb..678f60b1 100644 --- a/spec/services/maps/tile_usage/track_spec.rb +++ b/spec/services/maps/tile_usage/track_spec.rb @@ -5,16 +5,19 @@ require 'prometheus_exporter/client' RSpec.describe Maps::TileUsage::Track do describe '#call' do - subject(:track) { described_class.new(tile_count).call } + subject(:track) { described_class.new(user_id, tile_count).call } + let(:user_id) { 1 } let(:tile_count) { 5 } let(:prometheus_client) { instance_double(PrometheusExporter::Client) } before do allow(PrometheusExporter::Client).to receive(:default).and_return(prometheus_client) + allow(prometheus_client).to receive(:send_json) + allow(DawarichSettings).to receive(:prometheus_exporter_enabled?).and_return(true) end - it 'tracks tile usage' do + it 'tracks tile usage in prometheus' do expect(prometheus_client).to receive(:send_json).with( { type: 'counter', @@ -25,5 +28,15 @@ RSpec.describe Maps::TileUsage::Track do track end + + it 'tracks tile usage in cache' do + expect(Rails.cache).to receive(:write).with( + "dawarich_map_tiles_usage:#{user_id}:#{Time.zone.today}", + tile_count, + expires_in: 7.days + ) + + track + end end end From 491188d65d0cfde0f9074a5137f3f7c3d819aa96 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 13 Feb 2025 21:17:29 +0100 Subject: [PATCH 156/157] Update changelog --- .github/workflows/build_and_push.yml | 2 -- CHANGELOG.md | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index a0092f9f..49580227 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -70,5 +70,3 @@ jobs: platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache - provenance: false - outputs: type=registry,push=true diff --git a/CHANGELOG.md b/CHANGELOG.md index c121fd47..30c3a8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ To set a custom tile URL, go to the user settings and set the `Maps` section to ### Added - Safe settings for user with default values. +- Nominatim API is now supported as a reverse geocoding provider. - In the user settings, you can now set a custom tile URL for the map. #429 #715 - In the user map settings, you can now see a chart of map tiles usage. - 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: @@ -28,6 +29,11 @@ ruby_dawarich_map_tiles_usage 99 ### Fixed - Speed on the Points page is now being displayed in kilometers per hour. #700 +- Fog of war displacement #774 + +### Reverted + +- #748 # 0.24.0 - 2025-02-10 From 674b057d37eab4b478017a9ffb9047f1725c3221 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 13 Feb 2025 21:17:55 +0100 Subject: [PATCH 157/157] Update date in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c3a8fe..0aab7b37 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-11 +# 0.24.1 - 2025-02-13 ## Custom map tiles