Merge branch 'master' into fog-of-war

This commit is contained in:
Eugene Burmakin 2024-06-25 21:27:18 +02:00
commit b37841263e
104 changed files with 7609 additions and 749 deletions

View file

@ -1 +1 @@
0.4.2
0.7.2

2
.github/FUNDING.yml vendored
View file

@ -1,7 +1,7 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
patreon: freika # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: freika
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,33 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Version**
Include version of Dawarich you're experiencing problem on.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
If applicable, add logs from containers `dawarich_app` and `dawarich_sidekiq` to help explain your problem.
**Additional context**
Add any other context about the problem here.

2
.gitignore vendored
View file

@ -26,6 +26,8 @@
!/tmp/storage/.keep
/public/assets
/public/exports
/public/imports
# Ignore master key for decrypting credentials and more.
/config/master.key

View file

@ -5,6 +5,220 @@ 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.7.2] — 2024-06-25
### Added
- New Settings page to change Dawarich settings.
### Changed
- Calculation of city visits that are shown in right sidebar on Map page was reworked and now is more accurate.
- Order of points on Points page is now descending by timestamp instead of ascending.
---
## [0.7.1] — 2024-06-20
In new Settings page you can now change the following settings:
- Maximum distance between two points to consider them as one route
- Maximum time between two points to consider them as one route
### Added
- New Settings page to change Dawarich settings.
### Changed
- Settings link in user menu now redirects to the new Settings page.
- Old settings page is now available undeer Account link in user menu.
---
## [0.7.0] — 2024-06-19
## The GPX MVP Release
This release introduces support for GPX files to be imported. Now you can import GPX files from your devices to Dawarich. The import process is the same as for other kinds of files, just select the GPX file instead and choose "gpx" as a source. Both single-segmented and multi-segmented GPX files are supported.
⚠️ BREAKING CHANGES: ⚠️
- `/api/v1/points` endpoint is removed. Please use `/api/v1/owntracks/points` endpoint to upload your points from OwnTracks mobile app instead.
### Added
- Support for GPX files to be imported.
### Changed
- Couple of unnecessary params were hidden from route popup and now can be shown using `?debug=true` query parameter. This is useful for debugging purposes.
### Removed
- `/exports/download` endpoint is removed. Now you can download your exports directly from the Exports page.
- `/api/v1/points` endpoint is removed.
---
## [0.6.4] — 2024-06-18
### Added
- A link to Dawarich's website in the footer. It ain't much, but it's honest work.
### Fixed
- Fixed version badge in the navbar. Now it will show the correct version of the application.
### Changed
- Default map center location was changed.
---
## [0.6.3] — 2024-06-14
⚠️ IMPORTANT: ⚠️
Please update your `docker-compose.yml` file to include the following changes:
```diff
dawarich_sidekiq:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
volumes:
- gem_cache:/usr/local/bundle/gems
+ - public:/var/app/public
```
### Added
- Added a line with public volume to sidekiq's docker-compose service to allow sidekiq process to write to the public folder
### Fixed
- Fixed a bug where the export file was not being created in the public folder
---
## [0.6.2] — 2024-06-14
This is a debugging release. No changes were made to the application.
---
## [0.6.0] — 2024-06-12
### Added
- Exports page to list existing exports download them or delete them
### Changed
- Exporting process now is done in the background, so user can close the browser tab and come back later to download the file. The status of the export can be checked on the Exports page.
Deleting Export file will only delete the file, not the points in the database.
⚠️ BREAKING CHANGES: ⚠️
Volume, exposed to the host machine for placing files to import was changed. See the changes below.
Path for placing files to import was changed from `tmp/imports` to `public/imports`.
```diff
...
dawarich_app:
image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- gem_cache:/usr/local/bundle/gems
- - tmp:/var/app/tmp
+ - public:/var/app/public/imports
...
```
```diff
...
volumes:
db_data:
gem_cache:
shared_data:
- tmp:
+ public:
```
---
## [0.5.3] — 2024-06-10
### Added
- A data migration to remove points with 0.0, 0.0 coordinates. This is necessary to prevent errors when calculating distance in Stats page.
### Fixed
- Reworked code responsible for importing "Records.json" file from Google Takeout. Now it is more reliable and faster, and should not throw as many errors as before.
---
## [0.5.2] — 2024-06-08
### Added
- Test version of google takeout importing service for exports from users' phones
---
## [0.5.1] — 2024-06-07
### Added
- Background jobs concurrency now can be set with `BACKGROUND_PROCESSING_CONCURRENCY` env variable in `docker-compose.yml` file. Default value is 10.
- Hand-made favicon
### Changed
- Change minutes to days and hours on route popup
### Fixed
- Improved speed of "Stats" page loading by removing unnecessary queries
---
## [0.5.0] — 2024-05-31
### Added
- New buttons to quickly move to today's, yesterday's and 7 days data on the map
- "Download JSON" button to points page
- For debugging purposes, now user can use `?meters_between_routes=500` and `?minutes_between_routes=60` query parameters to set the distance and time between routes to split them on the map. This is useful to understand why routes might not be connected on the map.
- Added scale indicator to the map
### Changed
- Removed "Your data" page as its function was replaced by "Download JSON" button on the points page
- Hovering over a route now also shows time and distance to next route as well as time and distance to previous route. This allows user to understand why routes might not be connected on the map.
---
## [0.4.3] — 2024-05-30
### Added
- Now user can hover on a route and see when it started, when it ended and how much time it took to travel
### Fixed
- Timestamps in export form are now correctly assigned from the first and last points tracked by the user
- Routes are now being split based both on distance and time. If the time between two consecutive points is more than 60 minutes, the route is split into two separate routes. This improves visibility of the routes on the map.
---
## [0.4.2] — 2024-05-29
### Changed

View file

@ -1,35 +1,35 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3.3)
actionpack (= 7.1.3.3)
activesupport (= 7.1.3.3)
actioncable (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.3.3)
actionpack (= 7.1.3.3)
activejob (= 7.1.3.3)
activerecord (= 7.1.3.3)
activestorage (= 7.1.3.3)
activesupport (= 7.1.3.3)
actionmailbox (7.1.3.4)
actionpack (= 7.1.3.4)
activejob (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.3.3)
actionpack (= 7.1.3.3)
actionview (= 7.1.3.3)
activejob (= 7.1.3.3)
activesupport (= 7.1.3.3)
actionmailer (7.1.3.4)
actionpack (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activesupport (= 7.1.3.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.1.3.3)
actionview (= 7.1.3.3)
activesupport (= 7.1.3.3)
actionpack (7.1.3.4)
actionview (= 7.1.3.4)
activesupport (= 7.1.3.4)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@ -37,35 +37,35 @@ GEM
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.3.3)
actionpack (= 7.1.3.3)
activerecord (= 7.1.3.3)
activestorage (= 7.1.3.3)
activesupport (= 7.1.3.3)
actiontext (7.1.3.4)
actionpack (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3.3)
activesupport (= 7.1.3.3)
actionview (7.1.3.4)
activesupport (= 7.1.3.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.1.3.3)
activesupport (= 7.1.3.3)
activejob (7.1.3.4)
activesupport (= 7.1.3.4)
globalid (>= 0.3.6)
activemodel (7.1.3.3)
activesupport (= 7.1.3.3)
activerecord (7.1.3.3)
activemodel (= 7.1.3.3)
activesupport (= 7.1.3.3)
activemodel (7.1.3.4)
activesupport (= 7.1.3.4)
activerecord (7.1.3.4)
activemodel (= 7.1.3.4)
activesupport (= 7.1.3.4)
timeout (>= 0.4.0)
activestorage (7.1.3.3)
actionpack (= 7.1.3.3)
activejob (= 7.1.3.3)
activerecord (= 7.1.3.3)
activesupport (= 7.1.3.3)
activestorage (7.1.3.4)
actionpack (= 7.1.3.4)
activejob (= 7.1.3.4)
activerecord (= 7.1.3.4)
activesupport (= 7.1.3.4)
marcel (~> 1.0)
activesupport (7.1.3.3)
activesupport (7.1.3.4)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
@ -84,11 +84,11 @@ GEM
bigdecimal (3.1.8)
bootsnap (1.18.3)
msgpack (~> 1.2)
builder (3.2.4)
builder (3.3.0)
byebug (11.1.3)
chartkick (5.0.7)
coderay (1.1.3)
concurrent-ruby (1.2.3)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
content_disposition (1.0.0)
crack (1.0.0)
@ -118,7 +118,7 @@ GEM
down (5.4.2)
addressable (~> 2.8)
drb (2.2.1)
erubi (1.12.0)
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
factory_bot (6.4.6)
@ -145,7 +145,7 @@ GEM
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.7.2)
irb (1.13.1)
irb (1.13.2)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.7.2)
@ -161,12 +161,12 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
method_source (1.0.0)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (5.23.1)
minitest (5.24.0)
msgpack (1.7.2)
mutex_m (0.2.0)
net-imap (0.4.11)
net-imap (0.4.12)
date
net-protocol
net-pop (0.1.2)
@ -176,19 +176,19 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.5-aarch64-linux)
nokogiri (1.16.6-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.5-arm-linux)
nokogiri (1.16.6-arm-linux)
racc (~> 1.4)
nokogiri (1.16.5-arm64-darwin)
nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86-linux)
nokogiri (1.16.6-x86-linux)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-darwin)
nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
oj (3.16.3)
oj (3.16.4)
bigdecimal (>= 3.0)
optimist (3.1.0)
orm_adapter (0.5.0)
@ -205,8 +205,8 @@ GEM
pry-byebug (3.10.1)
byebug (~> 11.0)
pry (>= 0.13, < 0.15)
pry-rails (0.3.9)
pry (>= 0.10.4)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.1.2)
stringio
public_suffix (5.0.5)
@ -216,7 +216,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.0)
rack (3.0.11)
rack (3.1.4)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@ -224,20 +224,20 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.1.3.3)
actioncable (= 7.1.3.3)
actionmailbox (= 7.1.3.3)
actionmailer (= 7.1.3.3)
actionpack (= 7.1.3.3)
actiontext (= 7.1.3.3)
actionview (= 7.1.3.3)
activejob (= 7.1.3.3)
activemodel (= 7.1.3.3)
activerecord (= 7.1.3.3)
activestorage (= 7.1.3.3)
activesupport (= 7.1.3.3)
rails (7.1.3.4)
actioncable (= 7.1.3.4)
actionmailbox (= 7.1.3.4)
actionmailer (= 7.1.3.4)
actionpack (= 7.1.3.4)
actiontext (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activemodel (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
bundler (>= 1.15.0)
railties (= 7.1.3.3)
railties (= 7.1.3.4)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@ -245,9 +245,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.1.3.3)
actionpack (= 7.1.3.3)
activesupport (= 7.1.3.3)
railties (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@ -262,7 +262,7 @@ GEM
redis-client (0.22.1)
connection_pool
regexp_parser (2.9.2)
reline (0.5.7)
reline (0.5.9)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
@ -271,13 +271,13 @@ GEM
strscan (>= 3.0.9)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.0)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.2)
rspec-rails (6.1.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@ -339,29 +339,29 @@ GEM
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets-rails (3.5.1)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.0)
stringio (3.1.1)
strscan (3.1.0)
super_diff (0.12.1)
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
tailwindcss-rails (2.6.0)
tailwindcss-rails (2.6.1)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-aarch64-linux)
tailwindcss-rails (2.6.1-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-arm-linux)
tailwindcss-rails (2.6.1-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-arm64-darwin)
tailwindcss-rails (2.6.1-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-x86_64-darwin)
tailwindcss-rails (2.6.1-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-x86_64-linux)
tailwindcss-rails (2.6.1-x86_64-linux)
railties (>= 7.0.0)
thor (1.3.1)
timeout (0.4.1)
@ -382,8 +382,8 @@ GEM
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
will_paginate (4.0.0)
zeitwerk (2.6.14)
will_paginate (4.0.1)
zeitwerk (2.6.16)
PLATFORMS
aarch64-linux

View file

@ -1,23 +1,44 @@
# Dawarich
[Discord](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD)
[Discord](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika)
## Screenshots
![Map](screenshots/map.jpeg)
![Stats](screenshots/stats.jpeg)
![Import](screenshots/imports.jpeg)
Dawarich is a self-hosted web application to replace Google Timeline (aka Google Location History). It allows you to import your location history from Google Maps Timeline and Owntracks, view it on a map and see some statistics, such as the number of countries and cities visited, and distance traveled.
You can find changelog [here](CHANGELOG.md).
## Disclaimer
⚠️ The project is under very active development.
⚠️ Expect bugs and breaking changes.
⚠️ Do not delete your original Google Maps
Timeline data after importing it to Dawarich.
⚠️ Export your data from Dawarich using built-in
export functionality before updating to a new version.
⚠️ Try to keep Dawarich up-to-date to have the latest features and bug fixes.
## Usage
To track your location, install the [Owntracks app](https://owntracks.org/booklet/guide/apps/) or [Overland app](https://overland.p3k.app/) on your phone and configure it to send location updates to your Dawarich instance.
Currently, the app only supports [HTTP mode](https://owntracks.org/booklet/tech/http/).
### OwnTracks
The url to send the location updates to is `http://<your-dawarich-instance>/api/v1/owntracks/points?api_key=YOUR_API_KEY`.
Currently, the app only supports [HTTP mode](https://owntracks.org/booklet/tech/http/) of OwnTracks.
### Overland
The url to send the location updates to is `http://<your-dawarich-instance>/api/v1/overland/batches?api_key=YOUR_API_KEY`.
@ -30,6 +51,9 @@ To import your Google Maps Timeline data, download your location history from [G
- [How to import Google Takeout to Dawarich](https://github.com/Freika/dawarich/wiki/How-to-import-your-Google-Takeout-data)
- [How to Import Google Semantic History to Dawarich](https://github.com/Freika/dawarich/wiki/How-to-import-your-Google-Semantic-History-data)
- [How to track your location to Dawarich with Overland](https://github.com/Freika/dawarich/wiki/How-to-track-your-location-to-Dawarich-with-Overland)
- [How to track your location to Dawarich with OwnTracks](https://github.com/Freika/dawarich/wiki/How-to-track-your-location-to-Dawarich-with-OwnTracks)
- [How to export your data from Dawarich](https://github.com/Freika/dawarich/wiki/How-to-export-your-data-from-Dawarich)
## Features
@ -53,6 +77,8 @@ You can see the number of countries and cities visited, the distance traveled, a
You can import your Google Maps Timeline data into Dawarich as well as Owntracks data.
⚠️ **Note**: Import of huge Google Maps Timeline files may take a long time and consume a lot of memory. It also might temporarily consume a lot of disk space due to logs. Please make sure you have enough resources before starting the import. After import is completed, you can restart your docker container and logs will be removed.
## How to start the app locally
`docker-compose up` to start the app. The app will be available at `http://localhost:3000`.
@ -65,20 +91,14 @@ Copy the contents of the `docker-compose.yml` file to your server and run `docke
## Environment variables
```
MIN_MINUTES_SPENT_IN_CITY — minimum minutes between two points to consider them as visited the same city, e.g. `60`
MAP_CENTER — default map center, e.g. `55.7558,37.6176`
TIME_ZONE — time zone, e.g. `Europe/Berlin`
APPLICATION_HOST — host of the application, e.g. `localhost` or `dawarich.example.com`
```
| ENV var name | Description |
| ------------- | ------------- |
| MIN_MINUTES_SPENT_IN_CITY | minimum minutes between two points to consider them as visited the same city, e.g. `60` |
| MAP_CENTER | default map center, e.g. `[55.7522, 37.6156]` |
| TIME_ZONE | time zone, e.g. `Europe/Berlin`, full list is [here](https://github.com/Freika/dawarich/issues/27#issuecomment-2094721396) |
| APPLICATION_HOST | host of the application, e.g. `localhost` or `dawarich.example.com` |
| BACKGROUND_PROCESSING_CONCURRENCY (only for dawarich_sidekiq service) | Number of simultaneously processed background jobs, default is 10 |
## Screenshots
![Map](screenshots/map.jpeg)
![Stats](screenshots/stats.jpeg)
![Import](screenshots/imports.jpeg)
## Star History

File diff suppressed because one or more lines are too long

View file

@ -3,3 +3,4 @@
//= link_tree ../builds
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js
//= link favicon/browserconfig.xml

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="<%= asset_path 'favicon/mstile-150x150.png' %>"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View file

@ -0,0 +1,15 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1180.000000pt" height="1180.000000pt" viewBox="0 0 1180.000000 1180.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1180.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M570 5900 l0 -5900 5330 0 5330 0 0 5900 0 5900 -5330 0 -5330 0 0
-5900z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 610 B

View file

@ -0,0 +1,19 @@
{
"name": "Dawarich",
"short_name": "Dawarich",
"icons": [
{
"src": "<%= asset_path 'favicon/android-chrome-192x192.png' %>",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "<%= asset_path 'favicon/android-chrome-512x512.png' %>",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -13,3 +13,9 @@
*= require_tree .
*= require_self
*/
.emoji-icon {
font-size: 36px; /* Adjust size as needed */
text-align: center;
line-height: 36px; /* Same as font-size for perfect centering */
}

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
# TODO: Deprecate in 1.0
class Api::V1::PointsController < ApplicationController
skip_forgery_protection
def create
Rails.logger.info 'This endpoint will be deprecated in 1.0. Use /api/v1/owntracks/points instead'
Owntracks::PointCreatingJob.perform_later(point_params)
render json: {}, status: :ok
end
private
def point_params
params.permit!
end
end

View file

@ -1,51 +0,0 @@
# frozen_string_literal: true
class ExportController < ApplicationController
before_action :authenticate_user!
def index
@start_at = Time.zone.at(start_at)
@end_at = Time.zone.at(end_at)
end
def download
export = current_user.export_data(start_at:, end_at:)
send_data export, filename:, type: 'applocation/json', disposition: 'attachment'
end
private
def filename
first_point_datetime = Time.zone.at(start_at).to_s
last_point_datetime = Time.zone.at(end_at).to_s
"dawarich-export-#{first_point_datetime}-#{last_point_datetime}.json".gsub(' ', '_')
end
def start_at
first_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.first&.timestamp
@start_at ||=
if params[:start_at].nil? && first_point_timestamp.present?
first_point_timestamp
elsif params[:start_at].nil?
1.month.ago.to_i
else
Time.zone.parse(params[:start_at]).to_i
end
end
def end_at
last_point_timestamp = current_user.tracked_points.order(timestamp: :desc)&.last&.timestamp
@end_at ||=
if params[:end_at].nil? && last_point_timestamp.present?
last_point_timestamp
elsif params[:end_at].nil?
Time.zone.now.to_i
else
Time.zone.parse(params[:end_at]).to_i
end
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class ExportsController < ApplicationController
before_action :authenticate_user!
before_action :set_export, only: %i[destroy]
def index
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
end
def create
export_name = "#{params[:start_at].to_date}_#{params[:end_at].to_date}"
export = current_user.exports.create(name: export_name, status: :created)
ExportJob.perform_later(export.id, params[:start_at], params[:end_at])
redirect_to exports_url, notice: 'Export was successfully initiated. Please wait until it\'s finished.'
rescue StandardError => e
export&.destroy
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity
end
def destroy
@export.destroy
redirect_to exports_url, notice: 'Export was successfully destroyed.', status: :see_other
end
private
def set_export
@export = current_user.exports.find(params[:id])
end
def export_params
params.require(:export).permit(:name, :url, :status)
end
end

View file

@ -23,7 +23,11 @@ class ImportsController < ApplicationController
source: params[:import][:source]
)
import.update(raw_data: JSON.parse(File.read(file)))
file = File.read(file)
raw_data = params[:import][:source] == 'gpx' ? Hash.from_xml(file) : JSON.parse(file)
import.update(raw_data:)
import.id
end

View file

@ -1,11 +1,12 @@
# frozen_string_literal: true
class MapController < ApplicationController
before_action :authenticate_user!
def index
@points = current_user.tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at).order(timestamp: :asc)
@countries_and_cities = CountriesAndCities.new(@points).call
@countries_and_cities = Visits::Calculate.new(@points).uniq_visits
@coordinates =
@points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id)
.map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7] }

View file

@ -8,8 +8,8 @@ class PointsController < ApplicationController
current_user
.tracked_points
.without_raw_data
.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
.order(timestamp: :asc)
.where(timestamp: start_at..end_at)
.order(timestamp: :desc)
.paginate(page: params[:page], per_page: 50)
@start_at = Time.zone.at(start_at)

View file

@ -3,6 +3,17 @@
class SettingsController < ApplicationController
before_action :authenticate_user!
def index
end
def update
current_user.update(settings: settings_params)
flash.now[:notice] = 'Settings updated'
redirect_to settings_path, notice: 'Settings updated'
end
def theme
current_user.update(theme: params[:theme])
@ -14,4 +25,10 @@ class SettingsController < ApplicationController
redirect_back(fallback_location: root_path)
end
private
def settings_params
params.require(:settings).permit(:meters_between_routes, :minutes_between_routes)
end
end

View file

@ -1,8 +1,10 @@
# frozen_string_literal: true
class StatsController < ApplicationController
before_action :authenticate_user!
def index
@stats = current_user.stats.group_by(&:year).sort_by { _1 }.reverse
@stats = current_user.stats.group_by(&:year).sort.reverse
end
def show

View file

@ -37,10 +37,23 @@ module ApplicationHelper
%w[info success warning error accent secondary primary]
end
def countries_and_cities_stat(year, user)
data = Stat.year_cities_and_countries(year, user)
countries = data[:countries]
cities = data[:cities]
def countries_and_cities_stat_for_year(year, stats)
data = { countries: [], cities: [] }
stats.select { _1.year == year }.each do
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
end
data[:cities].flatten!.uniq!
data[:countries].flatten!.uniq!
"#{data[:countries].count} countries, #{data[:cities].count} cities"
end
def countries_and_cities_stat_for_month(stat)
countries = stat.toponyms.count { _1['country'] }
cities = stat.toponyms.sum { _1['cities'].count }
"#{countries} countries, #{cities} cities"
end
@ -91,4 +104,9 @@ module ApplicationHelper
def active_class?(link_path)
'btn-active' if current_page?(link_path)
end
def full_title(page_title = '')
base_title = 'Dawarich'
page_title.empty? ? base_title : "#{page_title} | #{base_title}"
end
end

View file

@ -0,0 +1,2 @@
module ExportsHelper
end

View file

@ -1,81 +1,48 @@
import { Controller } from "@hotwired/stimulus"
import L, { circleMarker } from "leaflet"
import "leaflet.heat"
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
// Connects to data-controller="maps"
export default class extends Controller {
static targets = ["container"]
static targets = ["container"];
connect() {
console.log("Map controller connected")
var markers = JSON.parse(this.element.dataset.coordinates)
var center = markers[markers.length - 1] || JSON.parse(this.element.dataset.center)
var center = (center === undefined) ? [52.516667, 13.383333] : center;
console.log("Map controller connected");
var map = L.map(this.containerTarget, {
layers: [this.osmMapLayer(), this.osmHotMapLayer()]
const markers = JSON.parse(this.element.dataset.coordinates);
let center = markers[markers.length - 1] || JSON.parse(this.element.dataset.center);
center = center === undefined ? [52.514568, 13.350111] : center;
const timezone = this.element.dataset.timezone;
const map = L.map(this.containerTarget, {
layers: [this.osmMapLayer(), this.osmHotMapLayer()],
}).setView([center[0], center[1]], 14);
var markersArray = this.markersArray(markers);
var markersLayer = L.layerGroup(markersArray);
var heatmapMarkers = markers.map(element => [element[0], element[1], 0.3]); // lat, lon, intensity
const markersArray = this.createMarkersArray(markers);
const markersLayer = L.layerGroup(markersArray);
const heatmapMarkers = markers.map((element) => [element[0], element[1], 0.3]);
// Function to calculate distance between two lat-lng points using Haversine formula
function haversineDistance(lat1, lon1, lat2, lon2) {
const toRad = x => x * Math.PI / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c * 1000; // Distance in meters
}
const polylinesLayer = this.createPolylinesLayer(markers, map, timezone);
const heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
var splitPolylines = [];
var currentPolyline = [];
// Process markers and split polylines based on the distance
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i].slice(0, 2));
} else {
var lastPoint = currentPolyline[currentPolyline.length - 1];
var currentPoint = markers[i].slice(0, 2);
var distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
if (distance > 500) {
splitPolylines.push([...currentPolyline]); // Use spread operator to clone the array
currentPolyline = [currentPoint];
} else {
currentPolyline.push(currentPoint);
}
}
}
// Add the last polyline if it exists
if (currentPolyline.length > 0) {
splitPolylines.push(currentPolyline);
}
// Batch adding polylines to the map
var polylineLayers = splitPolylines.map(polylineCoordinates =>
L.polyline(polylineCoordinates, { color: 'blue', opacity: 0.6, weight: 3 })
);
var polylinesLayer = L.layerGroup(polylineLayers).addTo(map);
var heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
var controlsLayer = {
"Points": markersLayer,
"Polylines": L.layerGroup(polylinesLayer),
"Heatmap": heatmapLayer
const controlsLayer = {
Points: markersLayer,
Polylines: polylinesLayer,
Heatmap: heatmapLayer,
};
L.control
.scale({
position: "bottomright",
metric: true,
imperial: false,
maxWidth: 120,
})
.addTo(map);
L.control.layers(this.baseMaps(), controlsLayer).addTo(map);
this.addTileLayer(map);
// markersLayer.addTo(map);
this.addLastMarker(map, markers);
}
@ -84,50 +51,35 @@ export default class extends Controller {
}
osmMapLayer() {
return L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
return L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© OpenStreetMap'
})
attribution: "© OpenStreetMap",
});
}
osmHotMapLayer() {
return L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
return L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France'
})
attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France",
});
}
baseMaps() {
return {
"OpenStreetMap": this.osmMapLayer(),
"OpenStreetMap.HOT": this.osmHotMapLayer()
}
OpenStreetMap: this.osmMapLayer(),
"OpenStreetMap.HOT": this.osmHotMapLayer(),
};
}
controlsLayer() {
return {
"Points": this.markersLayer,
"Polyline": this.polylineLayer
}
createMarkersArray(markersData) {
return markersData.map((marker) => {
const [lat, lon] = marker;
const popupContent = this.createPopupContent(marker);
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
});
}
markersArray(markers_data) {
var markersArray = []
for (var i = 0; i < markers_data.length; i++) {
var lat = markers_data[i][0];
var lon = markers_data[i][1];
var popupContent = this.popupContent(markers_data[i]);
var circleMarker = L.circleMarker([lat, lon], {radius: 4})
markersArray.push(circleMarker.bindPopup(popupContent).openPopup())
}
return markersArray
}
popupContent(marker) {
createPopupContent(marker) {
return `
<b>Timestamp:</b> ${this.formatDate(marker[4])}<br>
<b>Latitude:</b> ${marker[0]}<br>
@ -139,44 +91,160 @@ export default class extends Controller {
}
formatDate(timestamp) {
let date = new Date(timestamp * 1000); // Multiply by 1000 because JavaScript works with milliseconds
let timezone = this.element.dataset.timezone;
return date.toLocaleString('en-GB', { timeZone: timezone });
const date = new Date(timestamp * 1000);
const timezone = this.element.dataset.timezone;
return date.toLocaleString("en-GB", { timeZone: timezone });
}
addTileLayer(map) {
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>",
}).addTo(map);
}
addMarkers(map, markers_data) {
var markers = []
for (var i = 0; i < markers_data.length; i++) {
var lat = markers_data[i][0];
var lon = markers_data[i][1];
var popupContent = this.popupContent(markers_data[i]);
var circleMarker = L.circleMarker([lat, lon], {radius: 4})
markers.push(circleMarker.bindPopup(popupContent).openPopup())
}
L.layerGroup(markers).addTo(map);
}
addPolyline(map, markers) {
var coordinates = markers.map(element => element.slice(0, 2));
L.polyline(coordinates).addTo(map);
}
addLastMarker(map, markers) {
if (markers.length > 0) {
var lastMarker = markers[markers.length - 1].slice(0, 2)
const lastMarker = markers[markers.length - 1].slice(0, 2);
L.marker(lastMarker).addTo(map);
}
}
haversineDistance(lat1, lon1, lat2, lon2) {
const toRad = (x) => (x * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c * 1000; // Distance in meters
}
minutesToDaysHoursMinutes(minutes) {
const days = Math.floor(minutes / (24 * 60));
const hours = Math.floor((minutes % (24 * 60)) / 60);
minutes = minutes % 60;
let result = "";
if (days > 0) {
result += `${days}d `;
}
if (hours > 0) {
result += `${hours}h `;
}
if (minutes > 0) {
result += `${minutes}min`;
}
return result;
}
getUrlParameter(name) {
return new URLSearchParams(window.location.search).get(name);
}
addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
const originalStyle = { color: "blue", opacity: 0.6, weight: 3 };
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
polyline.setStyle(originalStyle);
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
const timeOnRoute = this.minutesToDaysHoursMinutes(minutes);
const distance = this.haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]);
const distanceToPrev = prevPoint ? this.haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A";
const distanceToNext = nextPoint ? this.haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A";
const timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : "N/A";
const timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : "N/A";
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
const isDebugMode = this.getUrlParameter("debug") === "true";
let popupContent = `
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Distance:</b> ${Math.round(distance)}m<br>
`;
if (isDebugMode) {
popupContent += `
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${this.minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${this.minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
`;
}
const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent);
polyline.on("mouseover", function () {
polyline.setStyle(highlightStyle);
startMarker.addTo(map);
endMarker.addTo(map).openPopup();
});
polyline.on("mouseout", function () {
polyline.setStyle(originalStyle);
map.closePopup();
map.removeLayer(startMarker);
map.removeLayer(endMarker);
});
}
createPolylinesLayer(markers, map, timezone) {
const splitPolylines = [];
let currentPolyline = [];
const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500;
const timeThresholdMinutes = parseInt(this.element.dataset.minutes_between_routes) || 60;
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i]);
} else {
const lastPoint = currentPolyline[currentPolyline.length - 1];
const currentPoint = markers[i];
const distance = this.haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
const timeDifference = (currentPoint[4] - lastPoint[4]) / 60;
if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) {
splitPolylines.push([...currentPolyline]);
currentPolyline = [currentPoint];
} else {
currentPolyline.push(currentPoint);
}
}
}
if (currentPolyline.length > 0) {
splitPolylines.push(currentPolyline);
}
return L.layerGroup(
splitPolylines.map((polylineCoordinates, index) => {
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
const prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null;
const nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null;
this.addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone);
return polyline;
})
).addTo(map);
}
}

11
app/jobs/export_job.rb Normal file
View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ExportJob < ApplicationJob
queue_as :exports
def perform(export_id, start_at, end_at)
export = Export.find(export_id)
Exports::Create.new(export:, start_at:, end_at:).call
end
end

View file

@ -2,6 +2,7 @@
class ImportGoogleTakeoutJob < ApplicationJob
queue_as :imports
sidekiq_options retry: false
def perform(import_id, json_string)
import = Import.find(import_id)

View file

@ -20,9 +20,11 @@ class ImportJob < ApplicationJob
def parser(source)
case source
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_records' then GoogleMaps::RecordsParser
when 'owntracks' then OwnTracks::ExportParser
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_records' then GoogleMaps::RecordsParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser
end
end
end

View file

@ -12,7 +12,7 @@ class ReverseGeocodingJob < ApplicationJob
result = Geocoder.search([point.latitude, point.longitude])
return if result.blank?
point.update(
point.update!(
city: result.first.city,
country: result.first.country
)

19
app/models/export.rb Normal file
View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Export < ApplicationRecord
belongs_to :user
enum status: { created: 0, processing: 1, completed: 2, failed: 3 }
validates :name, presence: true
before_destroy :delete_export_file
private
def delete_export_file
file_path = Rails.root.join('public', 'exports', "#{name}.json")
File.delete(file_path) if File.exist?(file_path)
end
end

View file

@ -8,5 +8,5 @@ class Import < ApplicationRecord
include ImportUploader::Attachment(:raw)
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2 }
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4 }
end

View file

@ -1,8 +1,6 @@
# frozen_string_literal: true
class Point < ApplicationRecord
# self.ignored_columns = %w[raw_data]
belongs_to :import, optional: true
belongs_to :user, optional: true

View file

@ -41,13 +41,16 @@ class Stat < ApplicationRecord
end
def self.year_cities_and_countries(year, user)
points = user.tracked_points.where(timestamp: DateTime.new(year).beginning_of_year..DateTime.new(year).end_of_year)
start_at = DateTime.new(year).beginning_of_year
end_at = DateTime.new(year).end_of_year
points = user.tracked_points.without_raw_data.where(timestamp: start_at..end_at)
data = CountriesAndCities.new(points).call
{
countries: data.map { _1[:country] }.uniq.count,
cities: data.sum { |country| country[:cities].count }
cities: data.sum { _1[:cities].count }
}
end

View file

@ -10,15 +10,10 @@ class User < ApplicationRecord
has_many :points, through: :imports
has_many :stats, dependent: :destroy
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :exports, dependent: :destroy
after_create :create_api_key
def export_data(start_at: nil, end_at: nil)
geopoints = time_framed_points(start_at, end_at)
::ExportSerializer.new(geopoints, email).call
end
def countries_visited
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
end
@ -59,16 +54,4 @@ class User < ApplicationRecord
save
end
def time_framed_points(start_at, end_at)
return points.without_raw_data if start_at.nil? && end_at.nil?
if start_at && end_at
points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
elsif start_at
points.without_raw_data.where('timestamp >= ?', start_at)
elsif end_at
points.without_raw_data.where('timestamp <= ?', end_at)
end
end
end

View file

@ -15,30 +15,34 @@ class ExportSerializer
private
def export_points
points.map do |point|
{
lat: point.latitude,
lon: point.longitude,
bs: battery_status(point),
batt: point.battery,
p: point.ping,
alt: point.altitude,
acc: point.accuracy,
vac: point.vertical_accuracy,
vel: point.velocity,
conn: connection(point),
SSID: point.ssid,
BSSID: point.bssid,
m: trigger(point),
tid: point.tracker_id,
tst: point.timestamp.to_i,
inrids: point.inrids,
inregions: point.in_regions,
topic: point.topic
}
points.in_groups_of(1000, false).flat_map do |group|
group.map { |point| export_point(point) }
end
end
def export_point(point)
{
lat: point.latitude,
lon: point.longitude,
bs: battery_status(point),
batt: point.battery,
p: point.ping,
alt: point.altitude,
acc: point.accuracy,
vac: point.vertical_accuracy,
vel: point.velocity,
conn: connection(point),
SSID: point.ssid,
BSSID: point.bssid,
m: trigger(point),
tid: point.tracker_id,
tst: point.timestamp.to_i,
inrids: point.inrids,
inregions: point.in_regions,
topic: point.topic
}
end
def battery_status(point)
case point.battery_status
when 'unplugged' then 'u'

View file

@ -8,9 +8,7 @@ class CountriesAndCities
def call
grouped_records = group_points
mapped_with_cities = map_with_cities(grouped_records)
filtered_cities = filter_cities(mapped_with_cities)
normalize_result(filtered_cities)
end
@ -50,7 +48,7 @@ class CountriesAndCities
{
country:,
cities: cities.map do |city, data|
{ city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for]}
{ city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for] }
end
}
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
class Exports::Create
def initialize(export:, start_at:, end_at:)
@export = export
@user = export.user
@start_at = start_at.to_datetime
@end_at = end_at.to_datetime
end
def call
export.update!(status: :processing)
pp "====Exporting data for #{user.email} from #{start_at} to #{end_at}"
points = time_framed_points
pp "====Exporting #{points.size} points"
data = ::ExportSerializer.new(points, user.email).call
file_path = Rails.root.join('public', 'exports', "#{export.name}.json")
File.open(file_path, 'w') { |file| file.write(data) }
export.update!(status: :completed, url: "exports/#{export.name}.json")
rescue StandardError => e
Rails.logger.error("====Export failed to create: #{e.message}")
export.update!(status: :failed)
end
private
attr_reader :user, :export, :start_at, :end_at
def time_framed_points
user
.tracked_points
.without_raw_data
.where('timestamp >= ? AND timestamp <= ?', start_at.to_i, end_at.to_i)
end
end

View file

@ -0,0 +1,71 @@
# frozen_string_literal: true
class GoogleMaps::PhoneTakeoutParser
attr_reader :import, :user_id
def initialize(import, user_id)
@import = import
@user_id = user_id
end
def call
points_data = parse_json
points = 0
points_data.each do |point_data|
next if Point.exists?(timestamp: point_data[:timestamp])
Point.create(
latitude: point_data[:latitude],
longitude: point_data[:longitude],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
topic: 'Google Maps Phone Timeline Export',
tracker_id: 'google-maps-phone-timeline-export',
import_id: import.id,
user_id:
)
points += 1
end
doubles = points_data.size - points
processed = points + doubles
{ raw_points: points_data.size, points:, doubles:, processed: }
end
private
def parse_json
import.raw_data['semanticSegments'].flat_map do |segment|
if segment.key?('timelinePath')
segment['timelinePath'].map do |point|
lat, lon = parse_coordinates(point['point'])
timestamp = DateTime.parse(point['time']).to_i
point_hash(lat, lon, timestamp, segment)
end
elsif segment.key?('visit')
lat, lon = parse_coordinates(segment['visit']['topCandidate']['placeLocation']['latLng'])
timestamp = DateTime.parse(segment['startTime']).to_i
point_hash(lat, lon, timestamp, segment)
end
end
end
def parse_coordinates(coordinates)
coordinates.split(', ').map { _1.chomp('°') }
end
def point_hash(lat, lon, timestamp, raw_data)
{
latitude: lat.to_f,
longitude: lon.to_f,
timestamp:,
raw_data:
}
end
end

View file

@ -1,6 +1,5 @@
# frozen_string_literal: true
require 'redis_client'
class GoogleMaps::RecordsParser
attr_reader :import
@ -11,6 +10,8 @@ class GoogleMaps::RecordsParser
def call(json)
data = parse_json(json)
return if Point.exists?(latitude: data[:latitude], longitude: data[:longitude], timestamp: data[:timestamp])
Point.create(
latitude: data[:latitude],
longitude: data[:longitude],
@ -30,6 +31,8 @@ class GoogleMaps::RecordsParser
latitude: json['latitudeE7'].to_f / 10**7,
longitude: json['longitudeE7'].to_f / 10**7,
timestamp: DateTime.parse(json['timestamp']).to_i,
altitude: json['altitude'],
velocity: json['velocity'],
raw_data: json
}
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
class Gpx::TrackParser
attr_reader :import, :json, :user_id
def initialize(import, user_id)
@import = import
@json = import.raw_data
@user_id = user_id
end
def call
segments = json['gpx']['trk']['trkseg']
if segments.is_a?(Array)
segments.each do |segment|
segment['trkpt'].each { create_point(_1) }
end
else
segments['trkpt'].each { create_point(_1) }
end
end
private
def create_point(point)
return if point['lat'].blank? || point['lon'].blank? || point['time'].blank?
return if point_exists?(point)
Point.create(
latitude: point['lat'].to_d,
longitude: point['lon'].to_d,
altitude: point['ele'].to_i,
timestamp: Time.parse(point['time']).to_i,
import_id: import.id,
user_id:
)
end
def point_exists?(point)
Point.exists?(
latitude: point['lat'].to_d,
longitude: point['lon'].to_d,
timestamp: Time.parse(point['time']).to_i,
user_id:
)
end
end

View file

@ -1,26 +0,0 @@
# frozen_string_literal: true
require 'oj'
class StreamHandler < Oj::ScHandler
attr_reader :import_id
def initialize(import_id)
@import_id = import_id
@buffer = {}
end
def hash_start
{}
end
def hash_end
ImportGoogleTakeoutJob.perform_later(import_id, @buffer.to_json)
@buffer = {}
end
def hash_set(_buffer, key, value)
@buffer[key] = value
end
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
class Visits::Calculate
def initialize(points)
@points = points
end
def call
normalize_result(city_visits)
end
def uniq_visits
# Only one visit per city per day
call.flat_map do |country|
{ country: country[:country], cities: country[:cities].uniq { [_1[:city], Time.at(_1[:timestamp]).to_date] } }
end
end
private
attr_reader :points
def group_points
points.sort_by(&:timestamp).reject { _1.city.nil? }.group_by(&:country)
end
def city_visits
group_points.transform_values do |grouped_points|
grouped_points
.group_by(&:city)
.transform_values { |city_points| identify_consecutive_visits(city_points) }
end
end
def identify_consecutive_visits(city_points)
visits = []
current_visit = []
city_points.each_cons(2) do |point1, point2|
time_diff = (point2.timestamp - point1.timestamp) / 60
if time_diff <= MIN_MINUTES_SPENT_IN_CITY
current_visit << point1 unless current_visit.include?(point1)
current_visit << point2
else
visits << create_visit(current_visit) if current_visit.size > 1
current_visit = []
end
end
visits << create_visit(current_visit) if current_visit.size > 1
visits
end
def create_visit(points)
{
city: points.first.city,
points:,
stayed_for: calculate_stayed_time(points),
last_timestamp: points.last.timestamp
}
end
def calculate_stayed_time(points)
return 0 if points.empty?
min_time = points.first.timestamp
max_time = points.last.timestamp
((max_time - min_time) / 60).round
end
def normalize_result(hash)
hash.map do |country, cities|
{
country:,
cities: cities.values.flatten
.select { |visit| visit[:stayed_for] >= MIN_MINUTES_SPENT_IN_CITY }
.map do |visit|
{
city: visit[:city],
points: visit[:points].count,
timestamp: visit[:last_timestamp],
stayed_for: visit[:stayed_for]
}
end
}
end.reject { |entry| entry[:cities].empty? }
end
end

View file

@ -0,0 +1,9 @@
<link rel="apple-touch-icon" sizes="180x180" href="<%= asset_path 'favicon/apple-touch-icon.png' %>">
<link rel="icon" type="image/png" sizes="32x32" href="<%= asset_path 'favicon/favicon-32x32.png' %>">
<link rel="icon" type="image/png" sizes="16x16" href="<%= asset_path 'favicon/favicon-16x16.png' %>">
<link rel="manifest" href="<%= asset_path 'favicon/site.webmanifest' %>">
<link rel="mask-icon" href="<%= asset_path 'favicon/safari-pinned-tab.svg' %>" color="#5bbad5">
<link rel="shortcut icon" href="<%= asset_path 'favicon/favicon.ico' %>">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="<%= asset_path 'favicon/browserconfig.xml' %>">
<meta name="theme-color" content="#ffffff">

View file

@ -7,12 +7,12 @@
Usage example:
<div role="tablist" class="tabs tabs-boxed">
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="OwnTracks" />
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="OwnTracks" checked />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
<p><code><%= api_v1_owntracks_points_url(api_key: current_user.api_key) %></code></p>
</div>
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="Overland" checked />
<input type="radio" name="my_tabs_2" role="tab" class="tab" aria-label="Overland" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
</div>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Account' %>
<div class="hero min-h-content bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="text-center lg:text-left">

View file

@ -1,32 +0,0 @@
<div class="w-full">
<div class='m-5'>
<h1 class='text-3xl font-bold'>Export Data</h1>
<div role="alert" class="alert alert-info my-5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>Default selected timeframes are based on first and last geopoint timestamps</span>
</div>
<%= form_with url: export_download_path, method: :get, data: { turbo: false } do |f| %>
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
<div class="w-full md:w-1/3">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
</div>
</div>
<div class="w-full md:w-1/3">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
</div>
</div>
<div class="w-full md:w-1/3">
<div class="flex flex-col space-y-2">
<%= f.submit "Download JSON", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
</div>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,50 @@
<% content_for :title, "Exports" %>
<div class="w-full">
<div class="flex justify-between items-center mb-5">
<h1 class="font-bold text-4xl">Exports</h1>
</div>
<div id="exports" class="min-w-full">
<% if @exports.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<p class="py-6">
Here you'll find your exports, created on <%= link_to 'Points', points_url, class: 'link' %> page. But now there are none.
</p>
</div>
</div>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Actions</th>
<th>Created at</th>
</tr>
</thead>
<tbody>
<% @exports.each do |export| %>
<tr>
<td><%= export.name %></td>
<td><%= export.status %></td>
<td>
<% if export.completed? %>
<%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %>
<% end %>
<%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
</td>
<td><%= export.created_at.strftime('%Y-%m-%d %H:%M:%S') %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>

View file

@ -11,12 +11,21 @@
</div>
<% end %>
<label class="form-control w-full max-w-xs my-5">
<div class="label">
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">Select source</span>
</label>
<div class="space-y-2">
<%= form.collection_radio_buttons :source, Import.sources.except('google_records'), :first, :first do |b| %>
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= b.radio_button(class: "radio radio-primary") %>
<span class="label-text"><%= b.text.humanize %></span>
</label>
</div>
<% end %>
</div>
<%= form.collection_radio_buttons :source, Import.sources.except('google_records'), :first, :first %>
</label>
</div>
<label class="form-control w-full max-w-xs my-5">
<div class="label">

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Imports' %>
<div class="w-full">
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Imports</h1>
@ -5,33 +7,46 @@
</div>
<div id="imports" class="min-w-full">
<div class="overflow-x-auto">
<table class="table">
<!-- head -->
<thead>
<tr>
<th>Name</th>
<th>Processed</th>
<th>Doubles</th>
<th>Created at</th>
</tr>
</thead>
<tbody>
<% @imports.each do |import| %>
<% if @imports.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<p class="py-6">
Here you'll find your imports, But now there are none. Let's <%= link_to 'create one', new_import_path, class: 'link' %>!
</p>
</div>
</div>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table">
<!-- head -->
<thead>
<tr>
<td>
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
</td>
<td>
<%= "✅" if import.processed == import.raw_points %>
<%= "#{import.processed}/#{import.raw_points}" %>
</td>
<td><%= import.doubles %></td>
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
<th>Name</th>
<th>Processed</th>
<th>Doubles</th>
<th>Created at</th>
</tr>
<% end %>
</tbody>
</table>
</div>
</thead>
<tbody>
<% @imports.each do |import| %>
<tr>
<td>
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
</td>
<td>
<%= "✅" if import.processed == import.raw_points %>
<%= "#{import.processed}/#{import.raw_points}" %>
</td>
<td><%= import.doubles %></td>
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'New Import' %>
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl">New import</h1>
@ -8,11 +10,24 @@
<p class='mb-3'>Import takes a while to finish, so you might want to run it in <code>screen</code> session.</p>
<p>1. Upload your Records.json file to your server</p>
<p>2. Copy you Records.json to the <code>tmp</code> folder: <code>$ docker cp Records.json dawarich_app:/var/app/tmp/Records.json</code></p>
<p>3. Attach to the docker container: <code>$ docker exec -it dawarich_app sh</code></p>
<p>4. Run the rake task: <code>$ bundle exec rake import:big_file['tmp/Records.json','user@example.com']</code></p>
<p>5. Wait patiently for process to finish</p>
<p class='mt-5 mb-2'>1. Upload your Records.json file to your server</p>
<p class='mt-5 mb-2'>2. Copy you Records.json to the <code>tmp</code> folder:
<div class="mockup-code">
<pre data-prefix="$"><code>docker cp Records.json dawarich_app:/var/app/public/imports/Records.json</code></pre>
</div>
</p>
<p class='mt-5 mb-2'>3. Attach to the docker container:
<div class="mockup-code">
<pre data-prefix="$"><code>docker exec -it dawarich_app sh</code></pre>
</div>
</p>
<p class='mt-5 mb-2'>4. Run the rake task:
<div class="mockup-code">
<pre data-prefix="$"><code>bundle exec rake import:big_file['public/imports/Records.json','user@example.com']</code>
</pre>
</div>
</p>
<p class='mt-5 mb-2'>5. Wait patiently for process to finish</p>
<p class='mt-3'>You can monitor progress in <a href="/sidekiq" class="underline">Sidekiq UI</a></p>
</span>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Import' %>
<div class="mx-auto md:w-2/3 w-full flex">
<div class="mx-auto">
<% if notice.present? %>

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html data-theme="<%= app_theme %>">
<head>
<title>DaWarIch</title>
<title><%= full_title(yield(:title)) %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@ -12,6 +12,7 @@
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= render 'application/favicon' %>
</head>
<body class='min-h-screen'>

View file

@ -1,38 +1,52 @@
<% content_for :title, 'Map' %>
<div class='w-4/5 mt-10'>
<div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: map_path, method: :get do |f| %>
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
<div class="w-full md:w-1/3">
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
</div>
</div>
<div class="w-full md:w-1/3">
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
</div>
</div>
<div class="w-full md:w-1/3">
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
</div>
</div>
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Today", map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
</div>
</div>
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
</div>
</div>
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
</div>
</div>
</div>
<% end %>
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Warning: Starting release 0.4.0 it's HIGHLY RECOMMENDED to switch from <code>/api/v1/points</code> to <code>/api/v1/owntracks/points</code> API endpoint. Please read more at <a href="https://github.com/Freika/dawarich/releases/tag/0.4.0" class='underline hover:no-underline'>0.4.0 release notes</a></span>
</div>
<div
class="w-full"
data-controller="maps"
data-coordinates="<%= @coordinates %>"
data-center="<%= MAP_CENTER %>"
data-timezone="<%= Rails.configuration.time_zone %>">
data-timezone="<%= Rails.configuration.time_zone %>"
data-meters_between_routes="<%= current_user.settings['meters_between_routes'] %>"
data-minutes_between_routes="<%= current_user.settings['minutes_between_routes'] %>">
<div data-maps-target="container" class="h-[25rem] w-auto min-h-screen"></div>
</div>
</div>

View file

@ -1,25 +1,30 @@
<% content_for :title, "Points" %>
<% content_for :title, 'Points' %>
<div class="w-full">
<%= form_with url: points_path, method: :get do |f| %>
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
<div class="w-full md:w-1/3">
<div class="w-full md:w-2/6">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
</div>
</div>
<div class="w-full md:w-1/3">
<div class="w-full md:w-2/6">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
</div>
</div>
<div class="w-full md:w-1/3">
<div class="w-full md:w-2/6">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
</div>
</div>
<div class="w-full md:w-2/6">
<div class="flex flex-col space-y-2 text-center">
<%= link_to 'Export points', exports_path(start_at: @start_at, end_at: @end_at), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points withing timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md" %>
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,65 @@
<% content_for :title, 'Settings' %>
<div class="hero min-h-content bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Edit your Dawarich settings!</h1>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :meters_between_routes do %>
Meters between routes
<!-- The button to open modal -->
<label for="meters_between_routes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="meters_between_routes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Meters between routes</h3>
<p class="py-4">
Value in meters.
</p>
<p class="py-4">
Points on the map are connected by lines. This value is the maximum distance between two points to be connected by a line. If the distance between two points is greater than this value, they will not be connected, and the line will not be drawn. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far from each other.
</p>
</div>
<label class="modal-backdrop" for="meters_between_routes_info">Close</label>
</div>
<% end %>
<%= f.number_field :meters_between_routes, value: current_user.settings['meters_between_routes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :minutes_between_routes do %>
Minutes between routes
<!-- The button to open modal -->
<label for="minutes_between_routes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="minutes_between_routes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Minutes between routes</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
Points on the map are connected by lines. This value is the maximum time between two points to be connected by a line. If the time between two points is greater than this value, they will not be connected. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far in time from each other.
</p>
</div>
<label class="modal-backdrop" for="minutes_between_routes_info">Close</label>
</div>
<% end %>
<%= f.number_field :minutes_between_routes, value: current_user.settings['minutes_between_routes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.submit "Update", class: "btn btn-primary" %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
<aside>
<p>Dawarich 2023-<%=Time.zone.now.year %></p>
<p><a href="https://dawarich.app/" class="link hover:no-underline" target="_blank">Dawarich</a> 2023-<%=Time.zone.now.year %></p>
</aside>
</footer>

View file

@ -9,6 +9,7 @@
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
</div>
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
@ -41,6 +42,7 @@
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
</div>
<div class="navbar-end">
@ -53,8 +55,8 @@
<%= "#{current_user.email}" %>
</summary>
<ul class="p-2 bg-base-100 rounded-t-none z-10">
<li><%= link_to 'Settings', edit_user_registration_path %></li>
<li><%= link_to 'Your data', export_path %></li>
<li><%= link_to 'Account', edit_user_registration_path %></li>
<li><%= link_to 'Settings', settings_path %></li>
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %></li>
</ul>
</details>

View file

@ -31,6 +31,7 @@
<% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %>
<hr class='my-5'>
<h2 class='text-lg font-semibold'>Countries and cities</h2>
<% @countries_and_cities.each do |country| %>
<% next if country[:cities].empty? %>

View file

@ -0,0 +1,47 @@
<div class="stat text-center">
<div class="stat-value text-secondary">
<%= number_with_delimiter current_user.total_reverse_geocoded %>
</div>
<div class="stat-title">Reverse geocoded points</div>
</div>
<div class="stat text-center">
<div class="stat-value text-warning underline hover:no-underline hover:cursor-pointer" onclick="countries_visited.showModal()">
<%= number_with_delimiter current_user.total_countries %>
</div>
<div class="stat-title">Countries visited</div>
<dialog id="countries_visited" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Countries visited</h3>
<p class="py-4">
<% current_user.countries_visited.each do |country| %>
<p><%= country %></p>
<% end %>
</p>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
<div class="stat text-center">
<div class="stat-value hover:cursor-pointer hover:no-underline underline" onclick="cities_visited.showModal()">
<%= current_user.total_cities %>
</div>
<div class="stat-title">Cities visited</div>
<dialog id="cities_visited" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Cities visited</h3>
<p class="py-4">
<% current_user.cities_visited.each do |city| %>
<p><%= city %></p>
<% end %>
</p>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>

View file

@ -8,7 +8,7 @@
<p><%= stat.distance %>km</p>
<% if REVERSE_GEOCODING_ENABLED %>
<div class="card-actions justify-end">
<%= stat.toponyms.count %> countries, <%= stat.toponyms.sum { _1['cities'].count } %> cities
<%= countries_and_cities_stat_for_month(stat) %>
</div>
<% end %>
<% if stat.daily_distance %>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Statistics' %>
<div class="w-full">
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
<div class="stat text-center">
@ -9,59 +11,13 @@
<div class="stat text-center">
<div class="stat-value text-success">
<%= number_with_delimiter current_user.tracked_points.without_raw_data.count(:id) %>
<%= number_with_delimiter current_user.tracked_points.count %>
</div>
<div class="stat-title">Geopoints tracked</div>
</div>
<% if REVERSE_GEOCODING_ENABLED %>
<div class="stat text-center">
<div class="stat-value text-secondary">
<%= number_with_delimiter current_user.total_reverse_geocoded %>
</div>
<div class="stat-title">Reverse geocoded points</div>
</div>
<div class="stat text-center">
<div class="stat-value text-warning underline hover:no-underline hover:cursor-pointer" onclick="countries_visited.showModal()">
<%= number_with_delimiter current_user.total_countries %>
</div>
<div class="stat-title">Countries visited</div>
<dialog id="countries_visited" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Countries visited</h3>
<p class="py-4">
<% current_user.countries_visited.each do |country| %>
<p><%= country %></p>
<% end %>
</p>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
<div class="stat text-center">
<div class="stat-value hover:cursor-pointer hover:no-underline underline" onclick="cities_visited.showModal()">
<%= current_user.total_cities %>
</div>
<div class="stat-title">Cities visited</div>
<dialog id="cities_visited" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Cities visited</h3>
<p class="py-4">
<% current_user.cities_visited.each do |city| %>
<p><%= city %></p>
<% end %>
</p>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
<%= render 'stats/reverse_geocoding_stats' %>
<% end %>
</div>
@ -78,13 +34,11 @@
<p>
<% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %>
<%= number_with_delimiter year_distance_stat_in_km(year, current_user) %>km
<% end %>
<% end %>
</p>
<% if REVERSE_GEOCODING_ENABLED %>
<div class="card-actions justify-end">
<% cache [current_user, 'countries_and_cities_stat', year], skip_digest: true do %>
<%= countries_and_cities_stat(year, current_user) %>
<% end %>
<%= countries_and_cities_stat_for_year(year, stats) %>
</div>
<% end %>
<%= column_chart(

View file

@ -1,3 +1,5 @@
<% content_for :title, "Statistics for #{@year} year" %>
<div class="w-full">
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
</div>

61
config/favicon.json Normal file
View file

@ -0,0 +1,61 @@
{
"master_picture": "app/assets/images/favicon.jpeg",
"favicon_design": {
"ios": {
"picture_aspect": "background_and_margin",
"background_color": "#ffffff",
"margin": "14%",
"assets": {
"ios6_and_prior_icons": false,
"ios7_and_later_icons": false,
"precomposed_icons": false,
"declare_only_default_icon": true
}
},
"desktop_browser": {
"design": "raw"
},
"windows": {
"picture_aspect": "no_change",
"background_color": "#da532c",
"on_conflict": "override",
"assets": {
"windows_80_ie_10_tile": false,
"windows_10_ie_11_edge_tiles": {
"small": false,
"medium": true,
"big": false,
"rectangle": false
}
}
},
"android_chrome": {
"picture_aspect": "background_and_margin",
"margin": "17%",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"manifest": {
"name": "Dawarich",
"display": "standalone",
"orientation": "not_set",
"on_conflict": "override",
"declared": true
},
"assets": {
"legacy_icon": false,
"low_resolution_icons": false
}
},
"safari_pinned_tab": {
"picture_aspect": "silhouette",
"theme_color": "#5bbad5"
}
},
"settings": {
"scaling_algorithm": "Mitchell",
"error_on_image_too_small": false,
"readme_file": false,
"html_code_file": false,
"use_path_as_is": false
}
}

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
Rswag::Ui.configure do |c|
# List the Swagger endpoints that you want to be documented through the
@ -8,7 +10,7 @@ Rswag::Ui.configure do |c|
# (under openapi_root) as JSON or YAML endpoints, then the list below should
# correspond to the relative paths for those endpoints.
c.swagger_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs'
c.openapi_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs'
# Add Basic Auth in case your API is private
# c.basic_auth_enabled = true

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
Sidekiq.configure_server do |config|
config.redis = { url: ENV['REDIS_URL'] }
end

View file

@ -0,0 +1,18 @@
# This file was generated by rails_favicon_generator, from
# https://realfavicongenerator.net/
# It makes files with .webmanifest extension first class files in the asset
# pipeline. This is to preserve this extension, as is it referenced in a call
# to asset_path in the _favicon.html.erb partial.
Rails.application.config.assets.configure do |env|
mime_type = 'application/manifest+json'
extensions = ['.webmanifest']
if Sprockets::VERSION.to_i >= 4
extensions << '.webmanifest.erb'
env.register_preprocessor(mime_type, Sprockets::ERBProcessor)
end
env.register_mime_type(mime_type, extensions: extensions)
end

View file

@ -7,11 +7,14 @@ Rails.application.routes.draw do
mount Rswag::Ui::Engine => '/api-docs'
mount Sidekiq::Web => '/sidekiq'
resources :settings, only: :index
patch 'settings', to: 'settings#update'
get 'settings/theme', to: 'settings#theme'
get 'export', to: 'export#index'
get 'export/download', to: 'export#download'
post 'settings/generate_api_key', to: 'settings#generate_api_key', as: :generate_api_key
resources :imports
resources :exports, only: %i[index create destroy]
resources :points, only: %i[index] do
collection do
delete :bulk_destroy
@ -27,8 +30,6 @@ Rails.application.routes.draw do
root to: 'home#index'
devise_for :users
post 'settings/generate_api_key', to: 'settings#generate_api_key', as: :generate_api_key
get 'map', to: 'map#index'
namespace :api do

View file

@ -1,7 +1,8 @@
---
:concurrency: 10
:concurrency: <%= ENV.fetch("BACKGROUND_PROCESSING_CONCURRENCY", 10) %>
:queues:
- default
- imports
- exports
- stats
- reverse_geocoding

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class RemovePointsWithoutCoordinates < ActiveRecord::Migration[7.1]
def up
points = Point.where('longitude = 0.0 OR latitude = 0.0')
Rails.logger.info "Found #{points.count} points without coordinates..."
points
.select { |point| point.raw_data['latitudeE7'].nil? && point.raw_data['longitudeE7'].nil? }
.each(&:destroy)
Rails.logger.info 'Points without coordinates removed.'
StatCreatingJob.perform_later(User.pluck(:id))
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20240525110530)
DataMigrate::Data.define(version: 20240610170930)

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateExports < ActiveRecord::Migration[7.1]
def change
create_table :exports do |t|
t.string :name, null: false
t.string :url
t.integer :status, default: 0, null: false
t.bigint :user_id, null: false
t.timestamps
end
add_index :exports, :status
add_index :exports, :user_id
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddSettingsToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :settings, :jsonb, default: {
meters_between_routes: 500,
minutes_between_routes: 60
}
end
end

17
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
ActiveRecord::Schema[7.1].define(version: 2024_06_20_205120) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -42,6 +42,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table "exports", force: :cascade do |t|
t.string "name", null: false
t.string "url"
t.integer "status", default: 0, null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["status"], name: "index_exports_on_status"
t.index ["user_id"], name: "index_exports_on_user_id"
end
create_table "imports", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id", null: false
@ -121,6 +135,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
t.datetime "updated_at", null: false
t.string "api_key", default: "", null: false
t.string "theme", default: "dark", null: false
t.jsonb "settings", default: {"meters_between_routes"=>500, "minutes_between_routes"=>60}
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end

View file

@ -25,7 +25,7 @@ services:
container_name: dawarich_app
volumes:
- gem_cache:/usr/local/bundle/gems
- tmp:/var/app/tmp
- public:/var/app/public
networks:
- dawarich
ports:
@ -44,7 +44,7 @@ services:
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
APPLICATION_HOST: localhost
TIME_ZONE: UTC
TIME_ZONE: Europe/London
depends_on:
- dawarich_db
- dawarich_redis
@ -53,6 +53,7 @@ services:
container_name: dawarich_sidekiq
volumes:
- gem_cache:/usr/local/bundle/gems
- public:/var/app/public
networks:
- dawarich
stdin_open: true
@ -68,6 +69,7 @@ services:
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
APPLICATION_HOST: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
depends_on:
- dawarich_db
- dawarich_redis
@ -77,4 +79,4 @@ volumes:
db_data:
gem_cache:
shared_data:
tmp:
public:

View file

@ -11,15 +11,17 @@ namespace :import do
raise 'User not found' unless user
import = user.imports.create(name: args[:file_path], source: :google_records)
handler = StreamHandler.new(import.id)
import_id = import.id
pp "Importing #{args[:file_path]} for #{user.email}, file size is #{File.size(args[:file_path])}... This might take a while, have patience!"
File.open(args[:file_path], 'r') do |content|
Oj.sc_parse(handler, content)
content = File.read(args[:file_path]); nil
data = Oj.load(content); nil
data['locations'].each do |json|
ImportGoogleTakeoutJob.perform_later(import_id, json.to_json)
end
pp "Imported #{args[:file_path]} for #{user.email} successfully!"
pp "Imported #{args[:file_path]} for #{user.email} successfully! Wait for the processing to finish. You can check the status of the import in the Sidekiq UI (http://<your-dawarich-url>/sidekiq)."
end
end

0
public/exports/.keep Normal file
View file

0
public/imports/.keep Normal file
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 546 KiB

10
spec/factories/exports.rb Normal file
View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :export do
name { 'export' }
url { 'exports/export.json' }
status { 1 }
user
end
end

View file

@ -0,0 +1,106 @@
{
"semanticSegments": [
{
"startTime": "2019-04-03T08:00:00.000+02:00",
"endTime": "2019-04-03T10:00:00.000+02:00",
"timelinePath": [
{
"point": "50.0506312°, 14.3439906°",
"time": "2019-04-03T08:14:00.000+02:00"
},
{
"point": "50.0506312°, 14.3439906°",
"time": "2019-04-03T08:46:00.000+02:00"
}
]
},
{
"startTime": "2019-04-03T08:13:57.000+02:00",
"endTime": "2019-04-03T20:10:18.000+02:00",
"startTimeTimezoneUtcOffsetMinutes": 120,
"endTimeTimezoneUtcOffsetMinutes": 120,
"visit": {
"hierarchyLevel": 0,
"probability": 0.8500000238418579,
"topCandidate": {
"placeId": "some random id",
"semanticType": "UNKNOWN",
"probability": 0.44970497488975525,
"placeLocation": {
"latLng": "50.0506312°, 14.3439906°"
}
}
}
}
],
"rawSignals": [
{
"activityRecord": {
"probableActivities": [
{
"type": "STILL",
"confidence": 0.9599999785423279
},
{
"type": "IN_VEHICLE",
"confidence": 0.009999999776482582
},
{
"type": "ON_FOOT",
"confidence": 0.009999999776482582
},
{
"type": "WALKING",
"confidence": 0.009999999776482582
},
{
"type": "UNKNOWN",
"confidence": 0.009999999776482582
},
{
"type": "IN_ROAD_VEHICLE",
"confidence": 0.009999999776482582
},
{
"type": "IN_RAIL_VEHICLE",
"confidence": 0.009999999776482582
},
{
"type": "IN_ROAD_VEHICLE",
"confidence": 0.009999999776482582
}
],
"timestamp": "2024-04-26T20:54:38.000+02:00"
}
},
{
"activityRecord": {
"probableActivities": [
{
"type": "STILL",
"confidence": 0.9900000095367432
},
{
"type": "UNKNOWN",
"confidence": 0.009999999776482582
}
],
"timestamp": "2024-04-26T20:55:45.000+02:00"
}
}
],
"userLocationProfile": {
"frequentPlaces": [
{
"placeId": "some random id",
"placeLocation": "50.0506312°, 14.3439906°",
"label": "WORK"
},
{
"placeId": "some random id",
"placeLocation": "50.0506312°, 14.3439906°",
"label": "HOME"
}
]
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ExportJob, type: :job do
let(:export) { create(:export) }
let(:start_at) { 1.day.ago }
let(:end_at) { Time.zone.now }
it 'calls the Exports::Create service class' do
expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:).and_call_original
described_class.perform_now(export.id, start_at, end_at)
end
end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Owntracks::PointCreatingJob, type: :job do

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Export, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:status).with_values(created: 0, processing: 1, completed: 2, failed: 3) }
end
end

View file

@ -5,4 +5,16 @@ RSpec.describe Import, type: :model do
it { is_expected.to have_many(:points).dependent(:destroy) }
it { is_expected.to belong_to(:user) }
end
describe 'enums' do
it do
is_expected.to define_enum_for(:source).with_values(
google_semantic_history: 0,
owntracks: 1,
google_records: 2,
google_phone_takeout: 3,
gpx: 4
)
end
end
end

View file

@ -8,6 +8,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:points).through(:imports) }
it { is_expected.to have_many(:stats) }
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
it { is_expected.to have_many(:exports).dependent(:destroy) }
end
describe 'callbacks' do
@ -23,19 +24,6 @@ RSpec.describe User, type: :model do
describe 'methods' do
let(:user) { create(:user) }
xdescribe '#export_data' do
subject { user.export_data }
let(:import) { create(:import, user:) }
let(:point) { create(:point, import:) }
it 'returns json' do
expect(subject).to include(user.email)
expect(subject).to include('dawarich-export')
expect(subject).to include(point.attributes.except('raw_data', 'id', 'created_at', 'updated_at', 'country', 'city', 'import_id').to_json)
end
end
describe '#countries_visited' do
subject { user.countries_visited }

View file

@ -7,6 +7,7 @@ require_relative '../config/environment'
abort('The Rails environment is running in production mode!') if Rails.env.production?
require 'rspec/rails'
require 'rswag/specs'
require 'sidekiq/testing'
require 'rake'

View file

@ -1,25 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Points', type: :request do
describe 'POST /api/v1/points' do
context 'with valid params' do
let(:params) do
{ lat: 1.0, lon: 1.0, tid: 'test', tst: Time.current.to_i, topic: 'iPhone 12 pro' }
end
it 'returns http success' do
post api_v1_points_path, params: params
expect(response).to have_http_status(:success)
end
it 'enqueues a job' do
expect do
post api_v1_points_path, params: params
end.to have_enqueued_job(Owntracks::PointCreatingJob)
end
end
end
end

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Exports', type: :request do
describe 'GET /create' do
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in create(:user)
end
it 'returns http success' do
get '/export'
expect(response).to have_http_status(:success)
end
end
end

View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/exports', type: :request do
let(:user) { create(:user) }
let(:params) { { start_at: 1.day.ago, end_at: Time.zone.now } }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'GET /index' do
context 'when user is not logged in' do
it 'redirects to the login page' do
get exports_url
expect(response).to redirect_to(new_user_session_url)
end
end
context 'when user is logged in' do
before do
sign_in user
end
it 'renders a successful response' do
get exports_url
expect(response).to be_successful
end
end
end
describe 'POST /create' do
before { sign_in user }
context 'with valid parameters' do
let(:points) { create_list(:point, 10, user: user, timestamp: 1.day.ago) }
it 'creates a new Export' do
expect { post exports_url, params: params }.to change(Export, :count).by(1)
end
it 'redirects to the exports index page' do
post exports_url, params: params
expect(response).to redirect_to(exports_url)
end
it 'enqeuues a job to process the export' do
ActiveJob::Base.queue_adapter = :test
expect { post exports_url, params: params }.to have_enqueued_job(ExportJob)
end
end
context 'with invalid parameters' do
let(:params) { { start_at: nil, end_at: nil } }
it 'does not create a new Export' do
expect { post exports_url, params: params }.to change(Export, :count).by(0)
end
it 'renders a response with 422 status (i.e. to display the "new" template)' do
post exports_url, params: params
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /destroy' do
let!(:export) { create(:export, user:, url: 'exports/export.json') }
before { sign_in user }
it 'destroys the requested export' do
expect { delete export_url(export) }.to change(Export, :count).by(-1)
end
it 'redirects to the exports list' do
delete export_url(export)
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

View file

@ -37,22 +37,43 @@ RSpec.describe 'Imports', type: :request do
describe 'POST /imports' do
context 'when user is logged in' do
let(:user) { create(:user) }
let(:file) { fixture_file_upload('owntracks/export.json', 'application/json') }
before { sign_in user }
it 'queues import job' do
expect do
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
end.to have_enqueued_job(ImportJob).on_queue('imports').at_least(1).times
context 'when importing owntracks data' do
let(:file) { fixture_file_upload('owntracks/export.json', 'application/json') }
it 'queues import job' do
expect do
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
end.to have_enqueued_job(ImportJob).on_queue('imports').at_least(1).times
end
it 'creates a new import' do
expect do
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
end.to change(user.imports, :count).by(1)
expect(response).to redirect_to(imports_path)
end
end
it 'creates a new import' do
expect do
post imports_path, params: { import: { source: 'owntracks', files: [file] } }
end.to change(user.imports, :count).by(1)
context 'when importing gpx data' do
let(:file) { fixture_file_upload('gpx/gpx_track_single_segment.gpx', 'application/gpx+xml') }
expect(response).to redirect_to(imports_path)
it 'queues import job' do
expect do
post imports_path, params: { import: { source: 'gpx', files: [file] } }
end.to have_enqueued_job(ImportJob).on_queue('imports').at_least(1).times
end
it 'creates a new import' do
expect do
post imports_path, params: { import: { source: 'gpx', files: [file] } }
end.to change(user.imports, :count).by(1)
expect(response).to redirect_to(imports_path)
end
end
end
end

View file

@ -68,4 +68,19 @@ RSpec.describe 'Settings', type: :request do
end
end
end
describe 'PATCH /settings' do
let(:user) { create(:user) }
let(:params) { { settings: { 'meters_between_routes' => '1000', 'minutes_between_routes' => '10' } } }
before do
sign_in user
end
it 'updates the user settings' do
patch '/settings', params: params
expect(user.reload.settings).to eq(params[:settings])
end
end
end

Some files were not shown because too many files have changed in this diff Show more