diff --git a/.app_version b/.app_version index 4240544f..48b91fd8 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.22.4 +0.24.1 diff --git a/.circleci/config.yml b/.circleci/config.yml index d0055f31..ff43fbcc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,10 +7,10 @@ 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 + - image: cimg/postgres:13.3-postgis environment: POSTGRES_USER: postgres POSTGRES_DB: test_database 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/build_and_push.yml b/.github/workflows/build_and_push.yml index 00a78a71..49580227 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -12,16 +12,19 @@ on: jobs: build-and-push-docker: - runs-on: ubuntu-latest + 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@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/.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/.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/CHANGELOG.md b/CHANGELOG.md index a34d9ea6..0aab7b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,163 @@ - # Change Log 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.24.1 - 2025-02-13 + +## 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. +- 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: + +``` +# HELP ruby_dawarich_map_tiles_usage +# TYPE ruby_dawarich_map_tiles_usage counter +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 + +## 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.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)") + +``` + +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 ?", 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 +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. + +### 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 +- 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 + +- `X-Dawarich-Version` header to the `GET /api/v1/health` endpoint response. + +# 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 @@ -215,7 +361,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: @@ -246,7 +392,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 ... ``` @@ -1187,7 +1333,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/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/Gemfile b/Gemfile index 103b7688..592c2fd3 100644 --- a/Gemfile +++ b/Gemfile @@ -19,9 +19,11 @@ 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' +gem 'rgeo' gem 'rswag-api' gem 'rswag-ui' gem 'shrine', '~> 3.6' @@ -30,6 +32,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 +57,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 128bf38f..63875f90 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 @@ -93,9 +102,9 @@ 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.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) content_disposition (1.0.0) crack (1.0.0) @@ -109,6 +118,8 @@ GEM data_migrate (11.2.0) activerecord (>= 6.1) railties (>= 6.1) + database_consistency (2.0.4) + activerecord (>= 3.2) date (3.4.1) debug (1.10.0) irb (~> 1.10) @@ -137,7 +148,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) @@ -149,19 +160,20 @@ 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) 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) 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) @@ -179,7 +191,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) @@ -212,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) @@ -232,12 +244,15 @@ 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) 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) @@ -248,17 +263,17 @@ 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) - puma (6.5.0) + puma (6.6.0) nio4r (~> 2.0) pundit (2.4.0) 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) @@ -297,11 +312,11 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.10.0) + rdoc (6.12.0) 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) @@ -311,8 +326,12 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.8) - rspec-core (3.13.2) + rexml (3.4.0) + rgeo (3.0.1) + rgeo-activerecord (8.0.0) + activerecord (>= 7.0) + rgeo (>= 3.0) + rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) @@ -320,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) @@ -328,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) @@ -340,7 +359,7 @@ GEM rswag-ui (2.16.0) actionpack (>= 5.2, < 8.1) railties (>= 5.2, < 8.1) - rubocop (1.69.2) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -352,7 +371,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.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) @@ -364,12 +383,13 @@ 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) 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) @@ -392,13 +412,15 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.2) + strong_migrations (2.2.0) + activerecord (>= 7) super_diff (0.15.0) attr_extras (>= 6.2.4) diff-lcs patience_diff - tailwindcss-rails (3.2.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) @@ -406,21 +428,21 @@ 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) 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) 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) @@ -439,9 +461,11 @@ PLATFORMS x86_64-linux DEPENDENCIES + activerecord-postgis-adapter! bootsnap chartkick data_migrate + database_consistency debug devise dotenv-rails @@ -465,6 +489,7 @@ DEPENDENCIES pundit rails (~> 8.0) redis + rgeo rspec-rails rswag-api rswag-specs @@ -478,6 +503,7 @@ DEPENDENCIES simplecov sprockets-rails stimulus-rails + strong_migrations super_diff tailwindcss-rails turbo-rails @@ -485,7 +511,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.3.4p94 + ruby 3.4.1p0 BUNDLED WITH 2.5.21 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 a087aab4..5aa76a1b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Donate using crypto: [0x6bAd13667692632f1bF926cA9B421bEe7EaEB8D4](https://ethers 📄 **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/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/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/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/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..c22778e7 --- /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(current_api_user.id, 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/points_controller.rb b/app/controllers/api/v1/points_controller.rb index a70dabdc..f09340b8 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -21,6 +21,20 @@ class Api::V1::PointsController < ApiController render json: serialized_points end + def create + Points::CreateJob.perform_later(batch_params, current_api_user.id) + + render json: { message: 'Points are being processed' } + 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 +44,14 @@ class Api::V1::PointsController < ApiController private + def point_params + 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/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/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/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/controllers/settings/maps_controller.rb b/app/controllers/settings/maps_controller.rb new file mode 100644 index 00000000..58e2fef6 --- /dev/null +++ b/app/controllers/settings/maps_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Settings::MapsController < ApplicationController + before_action :authenticate_user! + + 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 + 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/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/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 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/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 01fa6ad7..d2f59dbb 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -8,13 +8,10 @@ import { createMarkersArray } from "../maps/markers"; import { createPolylinesLayer, updatePolylinesOpacity, - updatePolylinesColors, - calculateSpeed, - getSpeedColor + updatePolylinesColors } 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"; @@ -33,6 +30,7 @@ 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"]; @@ -61,6 +59,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); @@ -68,7 +95,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]); @@ -78,7 +105,13 @@ export default class extends Controller { // Create a proper Leaflet layer for fog this.fogOverlay = createFogOverlay(); - this.areasLayer = L.layerGroup(); // Initialize areas layer + // 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(); this.photoMarkers = L.layerGroup(); this.setupScratchLayer(this.countryCodesMap); @@ -98,35 +131,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); @@ -183,8 +222,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, @@ -206,38 +245,18 @@ export default class extends Controller { this.setupSubscription(); } - // Add the toggle panel button - this.addTogglePanelButton(); + // Initialize tile monitor + this.tileMonitor = new TileMonitor(this.apiKey); - // 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 tile load event handlers to each base layer + Object.entries(this.baseMaps()).forEach(([name, layer]) => { + layer.on('tileload', () => { + this.tileMonitor.recordTileLoad(name); + }); }); - this.map.on('zoomend', () => { - if (document.getElementById('fog')) { - this.updateFog(this.markers, this.clearFogRadius); - } - }); + // Start monitoring + this.tileMonitor.startMonitoring(); } disconnect() { @@ -246,10 +265,18 @@ 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(); + } + + // Stop tile monitoring + if (this.tileMonitor) { + this.tileMonitor.stopMonitoring(); + } } setupSubscription() { @@ -375,8 +402,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), @@ -387,6 +413,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() { @@ -563,18 +616,23 @@ export default class extends Controller { fillOpacity: 0.5, }, }, - }, + } }); // 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); + 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); }); } @@ -786,164 +844,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/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/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 602c04be..974feb30 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -1,17 +1,26 @@ +// This controller is being used on: +// - trips/show +// - trips/edit +// - trips/new + 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"] @@ -23,9 +32,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 @@ -34,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) }) } @@ -42,16 +50,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) @@ -68,7 +72,6 @@ export default class extends Controller { }).addTo(this.map) const overlayMaps = { - "Points": this.markersLayer, "Route": this.polylinesLayer, "Photos": this.photoMarkers } @@ -80,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', @@ -88,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, @@ -112,6 +137,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() { @@ -149,9 +195,7 @@ 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) + marker.addTo(this.polylinesLayer) }) } @@ -175,7 +219,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 => [ @@ -187,7 +231,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() @@ -198,4 +241,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/javascript/maps/areas.js b/app/javascript/maps/areas.js index 10402c13..66d5442b 100644 --- a/app/javascript/maps/areas.js +++ b/app/javascript/maps/areas.js @@ -1,49 +1,83 @@ +import { showFlashMessage } from "./helpers"; + export function handleAreaCreated(areasLayer, layer, apiKey) { const radius = layer.getRadius(); const center = layer.getLatLng(); const formHtml = ` -
+

New Area

-
+
- - +
-
- +
+ +
`; - layer.bindPopup( - formHtml, { - maxWidth: "auto", - minWidth: 300 - } - ).openPopup(); + 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 areasLayer.addLayer(layer); + + // Bind the event handler immediately after opening the popup + setTimeout(() => { + const form = document.getElementById('circle-form'); + const saveButton = document.getElementById('save-area-btn'); + const nameInput = document.getElementById('circle-name'); + + 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()) { + nameInput.classList.add('input-error'); + return; + } + + const formData = new FormData(form); + + saveArea(formData, areasLayer, layer, apiKey); + }); + }, 100); // Small delay to ensure DOM is ready } export function saveArea(formData, areasLayer, layer, apiKey) { @@ -79,9 +113,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 => { @@ -104,6 +142,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); @@ -124,33 +164,91 @@ export function fetchAndDrawAreas(areasLayer, apiKey) { return response.json(); }) .then(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, + // 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 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); + + // 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) => { + e.stopPropagation(); + circle.openPopup(); + }); + + circlePath.addEventListener('mouseenter', (e) => { + e.stopPropagation(); + circle.setStyle({ + fillOpacity: 0.8, + weight: 3 + }); + }); + + circlePath.addEventListener('mouseleave', (e) => { + e.stopPropagation(); + circle.setStyle({ + fillOpacity: 0.5, + weight: 2 + }); + }); + } + }, 100); } }); }) diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index 482a161e..bc3acbb1 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,22 @@ export function createFogOverlay() { return L.Layer.extend({ onAdd: (map) => { initializeFogCanvas(map); + + // 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) => { const fog = document.getElementById('fog'); if (fog) { fog.remove(); } + // Clean up event listener + map.off('resize'); } }); } diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js index d55ee7fb..610a81dc 100644 --- a/app/javascript/maps/markers.js +++ b/app/javascript/maps/markers.js @@ -1,28 +1,163 @@ 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 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.circleMarker([lat, lon], { - renderer: renderer, // Use canvas renderer - radius: 4, - color: markerColor, - zIndexOffset: 1000, - pane: 'markerPane' - }).bindPopup(popupContent, { autoClose: false }); + return L.marker([lat, lon], { + icon: L.divIcon({ + className: 'custom-div-icon', + html: `
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + }), + draggable: true, + autoPan: true, + pointIndex: index, + pointId: pointId, + originalLat: lat, + originalLng: lon, + markerData: marker, // Store the complete marker data + renderer: renderer + }).bindPopup(createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit)) + .on('dragstart', function(e) { + this.closePopup(); + }) + .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; + // 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) { + 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) { + 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) { + const newLatLng = e.target.getLatLng(); + const pointId = e.target.options.pointId; + const pointIndex = e.target.options.pointIndex; + const originalMarkerData = e.target.options.markerData; + + 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.toString(), + longitude: newLatLng.lng.toString() + } + }) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + const map = e.target._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); + } + } + + // 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:', error); + this.setLatLng([e.target.options.originalLat, e.target.options.originalLng]); + alert('Failed to update point position. Please try again.'); + }); + }); }); } } +// 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) @@ -35,7 +170,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 @@ -53,14 +187,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(); + }); }); } diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index ba7e15cf..e48479d3 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -169,54 +169,165 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }); let hoverPopup = null; + let clickedLayer = 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) { + 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) { + handleMouseOut(e); + }); - if (distance < minDistance) { - minDistance = distance; - closestSegment = layer; + layer.on("click", function (e) { + handleClick(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; - }); + function handleMouseOver(e) { + // 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 ); - } - } - } - }); - // Apply highlight style to all segments + // Calculate speed if we have both markers + if (startMarkerData && endMarkerData) { + speed = startMarkerData[5] || endMarkerData[5] || 0; + } + } + } + + // 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'; // 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); + } + + 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 highlightStyle = { - weight: 5, - opacity: 1 - }; - - // Only change color to yellow if speed colors are disabled - if (!userSettings.speed_colored_routes) { - highlightStyle.color = '#ffff00'; + if (layer instanceof L.Polyline) { + const originalStyle = { + weight: 3, + opacity: userSettings.route_opacity, + color: layer.options.originalColor + }; + layer.setStyle(originalStyle); } - - layer.setStyle(highlightStyle); - } }); + 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); @@ -225,7 +336,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(clickedLayer.options.speed || 0)} km/h `; if (hoverPopup) { @@ -233,40 +344,54 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use } hoverPopup = L.popup() - .setLatLng(e.latlng) - .setContent(popupContent) - .openOn(map); - }); + .setLatLng(e.latlng) + .setContent(popupContent) + .openOn(map); - polylineGroup.on("mouseout", function () { - // Restore original style - 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 - }; + // Prevent the click event from propagating to the map + L.DomEvent.stopPropagation(e); + } - 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; + } + if (hoverPopup) { + map.closePopup(hoverPopup); + map.removeLayer(startMarker); + map.removeLayer(endMarker); } - map.removeLayer(startMarker); - map.removeLayer(endMarker); }); - polylineGroup.on("click", function () { - map.fitBounds(polylineGroup.getBounds()); - }); + // Keep the original group events as a fallback + polylineGroup.on("mouseover", handleMouseOver); + polylineGroup.on("mouseout", handleMouseOut); + polylineGroup.on("click", handleClick); } 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' + }); const splitPolylines = []; let currentPolyline = []; @@ -295,9 +420,11 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS splitPolylines.push(currentPolyline); } - return L.layerGroup( - splitPolylines.map((polylineCoordinates) => { + // Create the layer group with the polylines + const layerGroup = L.layerGroup( + splitPolylines.map((polylineCoordinates, groupIndex) => { 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 +436,74 @@ 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) { + L.DomEvent.stopPropagation(e); + segments.forEach(segment => { + segment.setStyle({ + weight: 8, + opacity: 1 + }); + if (map.hasLayer(segment)) { + segment.bringToFront(); + } + }); + }); + + segmentGroup.on('mouseout', function(e) { + 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); + + 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]}
diff --git a/app/javascript/maps/tile_monitor.js b/app/javascript/maps/tile_monitor.js new file mode 100644 index 00000000..0a1edc60 --- /dev/null +++ b/app/javascript/maps/tile_monitor.js @@ -0,0 +1,63 @@ +export class TileMonitor { + constructor(apiKey) { + this.apiKey = apiKey; + this.tileQueue = 0; + this.tileUpdateInterval = null; + } + + startMonitoring() { + // 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() { + this.tileQueue += 1; + } + + sendTileUsage() { + if (this.tileQueue === 0) return; + + const currentCount = this.tileQueue; + console.log('Sending tile usage batch:', currentCount); + + fetch('/api/v1/maps/tile_usage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + tile_usage: { + 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/jobs/import/google_takeout_job.rb b/app/jobs/import/google_takeout_job.rb index d962a304..02702cf7 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, locations, current_index) + locations_batch = Oj.load(locations) import = Import.find(import_id) - json = Oj.load(json_string) - - GoogleMaps::RecordsParser.new(import).call(json) + GoogleMaps::RecordsImporter.new(import, current_index).call(locations_batch) end end diff --git a/app/jobs/points/create_job.rb b/app/jobs/points/create_job.rb new file mode 100644 index 00000000..964c50f7 --- /dev/null +++ b/app/jobs/points/create_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Points::CreateJob < ApplicationJob + queue_as :default + + def perform(params, user_id) + data = Points::Params.new(params, user_id).call + + data.each_slice(1000) do |location_batch| + Point.upsert_all( + location_batch, + unique_by: %i[latitude longitude timestamp user_id], + returning: false + ) + end + end +end diff --git a/app/jobs/trips/create_path_job.rb b/app/jobs/trips/create_path_job.rb new file mode 100644 index 00000000..d64a39ec --- /dev/null +++ b/app/jobs/trips/create_path_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Trips::CreatePathJob < ApplicationJob + queue_as :default + + def perform(trip_id) + trip = Trip.find(trip_id) + + trip.calculate_path_and_distance + + trip.save! + 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/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/app/models/trip.rb b/app/models/trip.rb index 4a2b0302..5e094078 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -7,7 +7,13 @@ class Trip < ApplicationRecord validates :name, :started_at, :ended_at, presence: true - before_save :calculate_distance + before_save :calculate_path_and_distance + + 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) @@ -40,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 diff --git a/app/models/user.rb b/app/models/user.rb index f0413f68..97fb9fe0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,10 +13,20 @@ 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 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 @@ -94,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.try(:[], 'maps')&.try(:[], '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/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/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/google_maps/records_importer.rb b/app/services/google_maps/records_importer.rb new file mode 100644 index 00000000..c7edfb1f --- /dev/null +++ b/app/services/google_maps/records_importer.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class GoogleMaps::RecordsImporter + include Imports::Broadcaster + + BATCH_SIZE = 1000 + attr_reader :import, :current_index + + def initialize(import, current_index = 0) + @import = import + @batch = [] + @current_index = current_index + end + + def call(locations) + 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) + end + end + + private + + # rubocop:disable Metrics/MethodLength + def prepare_location_data(location) + { + latitude: location['latitudeE7'].to_f / 10**7, + longitude: location['longitudeE7'].to_f / 10**7, + timestamp: parse_timestamp(location), + 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, + created_at: Time.current, + updated_at: Time.current + } + end + # rubocop:enable Metrics/MethodLength + + def bulk_insert_points(batch) + unique_batch = deduplicate_batch(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 + create_notification("Failed to process location batch: #{e.message}") + end + + def deduplicate_batch(batch) + batch.uniq do |record| + [ + record[:latitude].round(7), + record[:longitude].round(7), + record[:timestamp], + record[:user_id] + ] + end + end + + def parse_timestamp(location) + 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/app/services/google_maps/records_parser.rb b/app/services/google_maps/records_parser.rb deleted file mode 100644 index 04ee4621..00000000 --- a/app/services/google_maps/records_parser.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -class GoogleMaps::RecordsParser - attr_reader :import - - def initialize(import) - @import = import - end - - def call(json) - data = parse_json(json) - - return if Point.exists?( - latitude: data[:latitude], - longitude: data[:longitude], - timestamp: data[:timestamp], - user_id: import.user_id - ) - - 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 - ) - end - - private - - def parse_json(json) - { - 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 - } - end -end diff --git a/app/services/gpx/track_parser.rb b/app/services/gpx/track_parser.rb index 65cbc3be..20c2837a 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,10 +23,12 @@ class Gpx::TrackParser private def parse_track(track) + return if track['trkseg'].blank? + 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/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/maps/tile_usage/track.rb b/app/services/maps/tile_usage/track.rb new file mode 100644 index 00000000..a2ec819d --- /dev/null +++ b/app/services/maps/tile_usage/track.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Maps::TileUsage::Track + 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', + value: @count + } + + PrometheusExporter::Client.default.send_json(metric_data) + 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/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/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/points/params.rb b/app/services/points/params.rb new file mode 100644 index 00000000..8c1b8a51 --- /dev/null +++ b/app/services/points/params.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Points::Params + attr_reader :data, :points, :user_id + + 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 unless params_valid?(point) + + { + 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, + user_id: user_id + } + end.compact + end + + private + + def battery_level(level) + value = (level.to_f * 100).to_i + + 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/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 9eec9de4..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) + 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/services/tasks/imports/google_records.rb b/app/services/tasks/imports/google_records.rb index 8f8839e3..70b5d389 100644 --- a/app/services/tasks/imports/google_records.rb +++ b/app/services/tasks/imports/google_records.rb @@ -1,9 +1,10 @@ # 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 + def initialize(file_path, user_email) @file_path = file_path @user = User.find_by(email: user_email) @@ -14,10 +15,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 +28,25 @@ class Tasks::Imports::GoogleRecords @user.imports.create(name: @file_path, source: :google_records).id end - def read_file - File.read(@file_path) - end + def process_file_in_batches(import_id) + batch = [] + index = 0 - def schedule_import_jobs(json_data, import_id) - json_data['locations'].each do |json| - Import::GoogleTakeoutJob.perform_later(import_id, json.to_json) + Oj.load_file(@file_path, mode: :compat) do |record| + next unless record.is_a?(Hash) && record['locations'] + + 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 + + Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch), index) if batch.any? end def log_start 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/services/users/safe_settings.rb b/app/services/users/safe_settings.rb new file mode 100644 index 00000000..dab0a516 --- /dev/null +++ b/app/services/users/safe_settings.rb @@ -0,0 +1,93 @@ +# 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 + return settings['live_map_enabled'] if settings.key?('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 8ce09ba9..8b5e51e0 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..0910f9b9 --- /dev/null +++ b/app/views/settings/maps/index.html.erb @@ -0,0 +1,71 @@ +<% content_for :title, "Map settings" %> + +
+ <%= render 'settings/navigation' %> + +
+

Maps settings

+
+ + + +
+
+ <%= 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.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' + ) %> +
+ +
+
+
+
+
diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb index cf5518ff..847c2df2 100644 --- a/app/views/trips/_form.html.erb +++ b/app/views/trips/_form.html.erb @@ -20,7 +20,9 @@ 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-started_at="<%= trip.started_at %>" + data-ended_at="<%= trip.ended_at %>" data-timezone="<%= Rails.configuration.time_zone %>">
@@ -62,7 +64,7 @@
<%= form.label :notes %> - <%= form.rich_text_area :notes %> + <%= form.rich_text_area :notes, class: 'trix-content-editor' %>
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 %>" diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb index f399eb3f..f4709aa5 100644 --- a/app/views/trips/show.html.erb +++ b/app/views/trips/show.html.erb @@ -24,7 +24,9 @@ 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-started_at="<%= @trip.started_at %>" + data-ended_at="<%= @trip.ended_at %>" data-timezone="<%= Rails.configuration.time_zone %>">
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"] %> 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/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index 37a318ed..47dbf379 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -15,5 +15,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', 'false') == '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 aa80b763..96ee30ee 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? @@ -21,5 +21,16 @@ class DawarichSettings def self_hosted? @self_hosted ||= SELF_HOSTED end + + 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 + + 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) 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/initializers/reddis.rb b/config/initializers/reddis.rb deleted file mode 100644 index e9066ffc..00000000 --- a/config/initializers/reddis.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module Reddis - def self.client - @client ||= Redis.new(url: ENV['REDIS_URL']) - end -end 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' diff --git a/config/initializers/strong_migrations.rb b/config/initializers/strong_migrations.rb new file mode 100644 index 00000000..ec978211 --- /dev/null +++ b/config/initializers/strong_migrations.rb @@ -0,0 +1,26 @@ +# Mark existing migrations as safe +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 +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/config/routes.rb b/config/routes.rb index 5b370c5d..ac6c27ae 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' @@ -70,9 +72,10 @@ 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] + resources :points, only: %i[index create update destroy] resources :visits, only: %i[update] resources :stats, only: :index @@ -98,6 +101,10 @@ Rails.application.routes.draw do get 'thumbnail', constraints: { id: %r{[^/]+} } end end + + namespace :maps do + resources :tile_usage, only: [:create] + end end end end 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/20250123151849_create_paths_for_trips.rb b/db/data/20250123151849_create_paths_for_trips.rb new file mode 100644 index 00000000..c78cffff --- /dev/null +++ b/db/data/20250123151849_create_paths_for_trips.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreatePathsForTrips < ActiveRecord::Migration[8.0] + def up + Trip.find_each do |trip| + Trips::CreatePathJob.perform_later(trip.id) + end + end + + def down + 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/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/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/migrate/20250120154555_add_unique_index_to_points.rb b/db/migrate/20250120154555_add_unique_index_to_points.rb new file mode 100644 index 00000000..0a408cf7 --- /dev/null +++ b/db/migrate/20250120154555_add_unique_index_to_points.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +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', + algorithm: :concurrently + end + + def down + 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 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/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 16db4226..b431351f 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: 2024_12_11_113119) 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" create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false @@ -156,14 +157,19 @@ 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", "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" @@ -195,6 +201,7 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) 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 @@ -219,6 +226,8 @@ ActiveRecord::Schema[8.0].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 diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 37b04015..41b65721 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -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 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 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.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: 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/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: 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..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: @@ -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: 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..5986e882 100644 --- a/spec/factories/trips.rb +++ b/spec/factories/trips.rb @@ -7,14 +7,20 @@ 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| - 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/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/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/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/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/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/jobs/points/create_job_spec.rb b/spec/jobs/points/create_job_spec.rb new file mode 100644 index 00000000..7fa14b15 --- /dev/null +++ b/spec/jobs/points/create_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::CreateJob, type: :job do + 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 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..60d288e3 --- /dev/null +++ b/spec/jobs/trips/create_path_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Trips::CreatePathJob, type: :job do + 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/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/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/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/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 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..574fa9c1 --- /dev/null +++ b/spec/requests/api/v1/maps/tile_usage_spec.rb @@ -0,0 +1,36 @@ +# 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) } + let(:user) { create(:user) } + + before do + allow(Maps::TileUsage::Track).to receive(:new).with(user.id, tile_count).and_return(track_service) + allow(track_service).to receive(:call) + end + + context 'when user is authenticated' do + 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(user.id, 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/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/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/requests/exports_spec.rb b/spec/requests/exports_spec.rb index c96ac744..2c5a6b72 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) @@ -72,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) @@ -85,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 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/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/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/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) 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_importer_spec.rb b/spec/services/google_maps/records_importer_spec.rb new file mode 100644 index 00000000..8ce4d69d --- /dev/null +++ b/spec/services/google_maps/records_importer_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe GoogleMaps::RecordsImporter do + describe '#call' do + subject(:parser) { described_class.new(import).call(locations) } + + let(:import) { create(:import) } + let(:time) { DateTime.new(2025, 1, 1, 12, 0, 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(:locations) { super()[0].merge('timestamp' => time.to_s).to_json } + + it 'creates a point' do + expect { parser }.to change(Point, :count).by(1) + end + end + + context 'when point already exists' do + 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: import, + latitude: 12.3456789, + longitude: 12.3456789, + timestamp: time.to_i + ) + end + + it 'does not create a point' do + expect { parser }.not_to change(Point, :count) + end + end + + context 'with timestampMs in milliseconds' do + 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) + end + end + + context 'with ISO 8601 timestamp' do + 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) + created_point = Point.last + expect(created_point.timestamp).to eq(time.to_i) + end + end + + context 'with timestamp in milliseconds' do + 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) + created_point = Point.last + expect(created_point.timestamp).to eq(time.to_i) + end + end + + context 'with timestamp in seconds' do + 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) + created_point = Point.last + expect(created_point.timestamp).to eq(time.to_i) + end + end + end +end diff --git a/spec/services/google_maps/records_parser_spec.rb b/spec/services/google_maps/records_parser_spec.rb deleted file mode 100644 index 44ec23b6..00000000 --- a/spec/services/google_maps/records_parser_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe GoogleMaps::RecordsParser do - describe '#call' do - subject(:parser) { described_class.new(import).call(json) } - - let(:import) { create(:import) } - let(:time) { Time.zone.now } - let(:json) do - { - 'latitudeE7' => 123_456_789, - 'longitudeE7' => 123_456_789, - 'altitude' => 0, - 'velocity' => 0 - } - end - - context 'with regular timestamp' do - let(:json) { super().merge('timestamp' => time.to_s) } - - it 'creates a point' do - expect { parser }.to change(Point, :count).by(1) - end - end - - context 'when point already exists' do - let(:json) { super().merge('timestamp' => time.to_s) } - - before do - create( - :point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789, - timestamp: Time.zone.now.to_i - ) - end - - it 'does not create a point' do - expect { parser }.not_to change(Point, :count) - end - end - - context 'with timestampMs in milliseconds' do - let(:json) { super().merge('timestampMs' => (time.to_f * 1000).to_i.to_s) } - - it 'creates a point using milliseconds timestamp' do - expect { parser }.to change(Point, :count).by(1) - end - end - - context 'with ISO 8601 timestamp' do - let(:json) { super().merge('timestamp' => time.iso8601) } - - it 'parses ISO 8601 timestamp correctly' do - expect { parser }.to change(Point, :count).by(1) - created_point = Point.last - expect(created_point.timestamp).to eq(time.to_i) - end - end - - context 'with timestamp in milliseconds' do - let(:json) { super().merge('timestamp' => (time.to_f * 1000).to_i.to_s) } - - it 'parses millisecond timestamp correctly' do - expect { parser }.to change(Point, :count).by(1) - created_point = Point.last - expect(created_point.timestamp).to eq(time.to_i) - end - end - - context 'with timestamp in seconds' do - let(:json) { super().merge('timestamp' => time.to_i.to_s) } - - it 'parses second timestamp correctly' do - expect { parser }.to change(Point, :count).by(1) - created_point = Point.last - expect(created_point.timestamp).to eq(time.to_i) - end - end - end -end \ No newline at end of file diff --git a/spec/services/gpx/track_parser_spec.rb b/spec/services/gpx/track_parser_spec.rb index b1026143..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 @@ -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 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/services/maps/tile_usage/track_spec.rb b/spec/services/maps/tile_usage/track_spec.rb new file mode 100644 index 00000000..678f60b1 --- /dev/null +++ b/spec/services/maps/tile_usage/track_spec.rb @@ -0,0 +1,42 @@ +# 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(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 in prometheus' do + expect(prometheus_client).to receive(:send_json).with( + { + type: 'counter', + name: 'dawarich_map_tiles_usage', + value: tile_count + } + ) + + 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 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/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 diff --git a/spec/services/points/params_spec.rb b/spec/services/points/params_spec.rb new file mode 100644 index 00000000..62f9b82b --- /dev/null +++ b/spec/services/points/params_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +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) } + 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, + user_id: user.id + } + end + + subject(:params) { described_class.new(json, user.id).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 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/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 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 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 cbc31e6d..e5b8bf01 100644 --- a/spec/swagger/api/v1/points_controller_spec.rb +++ b/spec/swagger/api/v1/points_controller_spec.rb @@ -58,7 +58,138 @@ 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 + 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, + 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, + 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] + } + } + + 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 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/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! 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/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 diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index beed0840..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: @@ -696,6 +825,109 @@ 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 + 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 + 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: + - 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 @@ -740,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 @@ -792,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 @@ -892,6 +1133,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: