Merge branch 'master' into gpx-imports

This commit is contained in:
Eugene Burmakin 2024-06-17 17:30:49 +02:00
commit 4fe13a7d3f
77 changed files with 1182 additions and 303 deletions

View file

@ -1 +1 @@
0.5.0
0.6.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,118 @@ 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.6.1] — 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

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)
@ -169,7 +169,7 @@ GEM
minitest (5.23.1)
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)
@ -191,7 +191,7 @@ GEM
racc (~> 1.4)
nokogiri (1.16.5-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)
@ -227,20 +227,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
@ -248,9 +248,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)
@ -265,7 +265,7 @@ GEM
redis-client (0.22.1)
connection_pool
regexp_parser (2.9.2)
reline (0.5.7)
reline (0.5.8)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
@ -342,9 +342,9 @@ 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)
@ -354,17 +354,17 @@ GEM
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)
@ -385,8 +385,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.15)
PLATFORMS
aarch64-linux

View file

@ -1,21 +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`.
@ -54,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`.
@ -66,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

@ -1,32 +0,0 @@
# frozen_string_literal: true
class ExportController < ApplicationController
before_action :authenticate_user!
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 ||= first_point_timestamp || 1.month.ago.to_i
end
def end_at
last_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.last&.timestamp
@end_at ||= last_point_timestamp || Time.current.to_i
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

@ -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

@ -38,6 +38,27 @@ export default class extends Controller {
return new URLSearchParams(window.location.search).get(name);
}
function minutesToDaysHoursMinutes(minutes) {
var days = Math.floor(minutes / (24 * 60));
var hours = Math.floor((minutes % (24 * 60)) / 60);
var minutes = minutes % 60;
var result = '';
if (days > 0) {
result += days + 'd ';
}
if (hours > 0) {
result += hours + 'h ';
}
if (minutes > 0) {
result += minutes + 'min';
}
return result;
}
function addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
// Define the original and highlight styles
const originalStyle = { color: 'blue', opacity: 0.6, weight: 3 };
@ -49,7 +70,10 @@ export default class extends Controller {
// Create the popup content for the route
var firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString('en-GB', { timeZone: timezone });
var lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString('en-GB', { timeZone: timezone });
var timeOnRoute = Math.round((endPoint[4] - startPoint[4]) / 60); // Time in minutes
// Make timeOnRoute look nice with split to days, hours and minutes
var minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
var timeOnRoute = minutesToDaysHoursMinutes(minutes);
// Calculate distances to previous and next points
var distanceToPrev = prevPoint ? haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : 'N/A';
@ -68,9 +92,9 @@ export default class extends Controller {
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(`
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute} min<br>
<b>Prev Route:</b> ${Math.round(distanceToPrev)} m, ${timeBetweenPrev} min away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)} m, ${timeBetweenNext} min away<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
`);
// Add mouseover event to highlight the polyline and show the start and end markers

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

@ -22,6 +22,7 @@ class ImportJob < ApplicationJob
case source
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

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, gpx: 3 }
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 3 }
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 tracked_points.without_raw_data if start_at.nil? && end_at.nil?
if start_at && end_at
tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
elsif start_at
tracked_points.without_raw_data.where('timestamp >= ?', start_at)
elsif end_at
tracked_points.without_raw_data.where('timestamp <= ?', end_at)
end
end
end

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

@ -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,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

@ -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

@ -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,3 +1,5 @@
<% 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| %>
@ -21,27 +23,22 @@
</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-blue-500 text-white rounded-md" %>
<%= 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-blue-500 text-white rounded-md" %>
<%= 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-blue-500 text-white rounded-md" %>
<%= 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"

View file

@ -1,4 +1,4 @@
<% content_for :title, "Points" %>
<% content_for :title, 'Points' %>
<div class="w-full">
<%= form_with url: points_path, method: :get do |f| %>
@ -22,7 +22,7 @@
</div>
<div class="w-full md:w-2/6">
<div class="flex flex-col space-y-2 text-center">
<%= link_to 'Download JSON', export_download_path(start_at: @start_at, end_at: @end_at), data: { turbo: false }, class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= 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>

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">

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

@ -11,6 +11,7 @@ Rails.application.routes.draw do
get 'export/download', to: 'export#download'
resources :imports
resources :exports, only: %i[index create destroy]
resources :points, only: %i[index] do
collection do
delete :bulk_destroy

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

16
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_12_152451) 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

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"
}
]
}
}

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

@ -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,8 @@ 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 { is_expected.to define_enum_for(:source).with_values(google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3) }
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

@ -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

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Exports::Create do
describe '#call' do
subject(:create_export) { described_class.new(export:, start_at:, end_at:).call }
let(:user) { create(:user) }
let(:start_at) { DateTime.new(2021, 1, 1).to_s }
let(:end_at) { DateTime.new(2021, 1, 2).to_s }
let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
let(:export) { create(:export, user:, name: export_name, status: :created) }
let(:export_content) { ExportSerializer.new(points, user.email).call }
let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_datetime.to_i) }
it 'writes the data to a file' do
create_export
file_path = Rails.root.join('public', 'exports', "#{export_name}.json")
expect(File.read(file_path)).to eq(export_content)
end
it 'updates the export url' do
create_export
expect(export.reload.url).to eq("exports/#{export.name}.json")
end
context 'when an error occurs' do
before do
allow(File).to receive(:open).and_raise(StandardError)
end
it 'updates the export status to failed' do
create_export
expect(export.reload.failed?).to be_truthy
end
end
end
end

View file

@ -180,7 +180,7 @@ paths:
lat: 52.502397
lon: 13.356718
tid: Swagger
tst: 1717182089
tst: 1718385215
servers:
- url: http://{defaultHost}
variables: