Merge branch 'master' into import_google_formats

This commit is contained in:
Evgenii Burmakin 2024-12-25 11:53:40 +01:00 committed by GitHub
commit 52a032acb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 2092 additions and 773 deletions

View file

@ -1 +1 @@
0.19.7
0.21.1

41
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,41 @@
# Basis-Image für Ruby und Node.js
FROM ruby:3.3.4-alpine
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.9
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV TMP_PATH=/tmp/
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
# Install dependencies for application
RUN apk -U add --no-cache \
build-base \
git \
postgresql-dev \
postgresql-client \
libxml2-dev \
libxslt-dev \
nodejs \
yarn \
imagemagick \
tzdata \
less \
yaml-dev \
# gcompat for nokogiri on mac m1
gcompat \
&& rm -rf /var/cache/apk/* \
&& mkdir -p $APP_PATH
RUN gem update --system 3.5.7 && gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
# FIXME It would be a good idea to use a other user than root, but this lead to permission error on export and maybe more yet.
# RUN adduser -D -h ${APP_PATH} vscode
USER root
# Navigate to app directory
WORKDIR $APP_PATH
EXPOSE $RAILS_PORT

View file

@ -0,0 +1,17 @@
{
"name": "Ruby and Node DevContainer",
"dockerComposeFile": ["docker-compose.yml"],
"service": "dawarich_dev",
"settings": {
"terminal.integrated.defaultProfile.linux": "bash"
},
"extensions": [
"rebornix.ruby", // Ruby-Support
"esbenp.prettier-vscode", // Prettier for JS-Formating
"dbaeumer.vscode-eslint" // ESLint for JavaScript
],
"postCreateCommand": "yarn install && bundle config set --local path 'vendor/bundle' && bundle install --jobs 20 --retry 5",
"forwardPorts": [3000], // Redirect to Rails-App-Server
"remoteUser": "root",
"workspaceFolder": "/var/app"
}

View file

@ -0,0 +1,79 @@
networks:
dawarich:
services:
dawarich_dev:
build:
context: .
dockerfile: Dockerfile
container_name: dawarich_dev
volumes:
- "${PWD}:/var/app:cached"
- dawarich_gem_cache_app:/usr/local/bundle/gems_app
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
- dawarich
ports:
- 3000:3000
- 9394:9394
stdin_open: true
tty: true
environment:
RAILS_ENV: development
REDIS_URL: redis://dawarich_redis:6379/0
DATABASE_HOST: dawarich_db
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
PHOTON_API_HOST: photon.komoot.io
PHOTON_API_USE_HTTPS: true
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
ENABLE_TELEMETRY: false # More on telemetry: https://dawarich.app/docs/tutorials/telemetry
dawarich_redis:
image: redis:7.0-alpine
container_name: dawarich_redis
command: redis-server
networks:
- dawarich
volumes:
- dawarich_shared:/data
restart: always
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
dawarich_db:
image: postgres:14.2-alpine
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
- dawarich_shared:/var/shared
networks:
- dawarich
restart: always
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
volumes:
dawarich_db_data:
dawarich_gem_cache_app:
dawarich_gem_cache_sidekiq:
dawarich_shared:
dawarich_public:
dawarich_watched:

10
.gitignore vendored
View file

@ -53,3 +53,13 @@
.env
.byebug_history
.devcontainer/.onCreateCommandMarker
.devcontainer/.postCreateCommandMarker
.devcontainer/.updateContentCommandMarker
.vscode-server/
.ash_history
.cache/
.dotnet/

View file

@ -5,6 +5,123 @@ 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.21.1 - 2024-12-24
### Added
- Cache cleaning and preheating upon application start.
- `PHOTON_API_KEY` env var to set Photon API key. It's an optional env var, but it's required if you want to use Photon API as a Patreon supporter.
- 'X-Dawarich-Response' header to the `GET /api/v1/health` endpoint. It's set to 'Hey, I\'m alive!' to make it easier to check if the API is working.
### Changed
- Custom config for PostgreSQL is now optional in `docker-compose.yml`.
# 0.21.0 - 2024-12-20
⚠️ This release introduces a breaking change. ⚠️
The `dawarich_db` service now uses a custom `postgresql.conf` file.
As @tabacha pointed out in #549, the default `shm_size` for the `dawarich_db` service is too small and it may lead to database performance issues. This release introduces a `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. Also, it introduces a custom `postgresql.conf` file to the `dawarich_db` service.
To mount a custom `postgresql.conf` file, you need to create a `postgresql.conf` file in the `dawarich_db` service directory and add the following line to it:
```diff
dawarich_db:
image: postgres:14.2-alpine
shm_size: 1G
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
- dawarich_shared:/var/shared
+ - ./postgresql.conf:/etc/postgresql/postgres.conf # Provide path to custom config
...
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
+ command: postgres -c config_file=/etc/postgresql/postgres.conf # Use custom config
```
To ensure your database is using custom config, you can connect to the container (`docker exec -it dawarich_db psql -U postgres`) and run `SHOW config_file;` command. It should return the following path: `/etc/postgresql/postgresql.conf`.
An example of a custom `postgresql.conf` file is provided in the `postgresql.conf.example` file.
### Added
- A button on a year stats card to update stats for the whole year. #466
- A button on a month stats card to update stats for a specific month. #466
- A confirmation alert on the Notifications page before deleting all notifications.
- A `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations.
```diff
...
dawarich_db:
image: postgres:14.2-alpine
+ shm_size: 1G
...
```
- In addition to `api_key` parameter, `Authorization` header is now being used to authenticate API requests. #543
Example:
```
Authorization: Bearer YOUR_API_KEY
```
### Changed
- The map borders were expanded to make it easier to scroll around the map for New Zealanders.
- The `dawarich_db` service now uses a custom `postgresql.conf` file.
- The popup over polylines now shows dates in the user's format, based on their browser settings.
# 0.20.2 - 2024-12-17
### Added
- A point id is now being shown in the point popup.
### Fixed
- North Macedonia is now being shown on the scratch map. #537
### Changed
- The app process is now bound to :: instead of 0.0.0.0 to provide compatibility with IPV6.
- The app was updated to use Rails 8.0.1.
# 0.20.1 - 2024-12-16
### Fixed
- Setting `reverse_geocoded_at` for points that don't have geodata is now being performed in background job, in batches of 10,000 points to prevent memory exhaustion and long-running data migration.
# 0.20.0 - 2024-12-16
### Added
- `GET /api/v1/points/tracked_months` endpoint added to get list of tracked years and months.
- `GET /api/v1/countries/visited_cities` endpoint added to get list of visited cities.
- A link to the docs leading to a help chart for k8s. #550
- A button to delete all notifications. #548
- A support for `RAILS_LOG_LEVEL` env var to change log level. More on that here: https://guides.rubyonrails.org/debugging_rails_applications.html#log-levels. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`, corresponding to the log level numbers from 0 up to 5, respectively. The default log level is `:debug`. #540
- A devcontainer to improve developers experience. #546
### Fixed
- A point popup is no longer closes when hovering over a polyline. #536
- When polylines layer is disabled and user deletes a point from its popup, polylines layer is no longer being enabled right away. #552
- Paths to gems within the sidekiq and app containers. #499
### Changed
- Months and years navigation is moved to a map panel on the right side of the map.
- List of visited cities is now being shown in a map panel on the right side of the map.
# 0.19.7 - 2024-12-11
### Fixed

21
DEVELOPMENT.md Normal file
View file

@ -0,0 +1,21 @@
If you want to develop with dawarich you can use the devcontainer, with your IDE. It is tested with visual studio code.
Load the directory in Vs-Code and press F1. And Run the command: `Dev Containers: Rebuild Containers` after a while you should see a terminal.
Now you can create/prepare the Database (this need to be done once):
```bash
bundle exec rails db:prepare
```
Afterwards you can run sidekiq:
```bash
bundle exec sidekiq
```
And in a second terminal the dawarich-app:
```bash
bundle exec bin/dev
```
You can connect with a web browser to http://127.0.0.l:3000/ and login with the default credentials.

View file

@ -21,7 +21,7 @@ gem 'pg'
gem 'prometheus_exporter'
gem 'puma'
gem 'pundit'
gem 'rails'
gem 'rails', '~> 8.0'
gem 'rswag-api'
gem 'rswag-ui'
gem 'shrine', '~> 3.6'

View file

@ -10,66 +10,65 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
actioncable (8.0.1)
actionpack (= 8.0.1)
activesupport (= 8.0.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actionmailbox (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
mail (>= 2.8.0)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
actionmailer (8.0.1)
actionpack (= 8.0.1)
actionview (= 8.0.1)
activejob (= 8.0.1)
activesupport (= 8.0.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
actionpack (8.0.1)
actionview (= 8.0.1)
activesupport (= 8.0.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.2)
actionpack (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actiontext (8.0.1)
actionpack (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.2)
activesupport (= 7.2.2)
actionview (8.0.1)
activesupport (= 8.0.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.2)
activesupport (= 7.2.2)
activejob (8.0.1)
activesupport (= 8.0.1)
globalid (>= 0.3.6)
activemodel (7.2.2)
activesupport (= 7.2.2)
activerecord (7.2.2)
activemodel (= 7.2.2)
activesupport (= 7.2.2)
activemodel (8.0.1)
activesupport (= 8.0.1)
activerecord (8.0.1)
activemodel (= 8.0.1)
activesupport (= 8.0.1)
timeout (>= 0.4.0)
activestorage (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activesupport (= 7.2.2)
activestorage (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
activerecord (= 8.0.1)
activesupport (= 8.0.1)
marcel (~> 1.0)
activesupport (7.2.2)
activesupport (8.0.1)
base64
benchmark (>= 0.3)
bigdecimal
@ -81,6 +80,7 @@ GEM
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
@ -105,12 +105,12 @@ GEM
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv (3.3.2)
data_migrate (11.2.0)
activerecord (>= 6.1)
railties (>= 6.1)
date (3.4.0)
debug (1.9.2)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4)
@ -121,14 +121,14 @@ GEM
warden (~> 1.2.3)
diff-lcs (1.5.1)
docile (1.4.1)
dotenv (3.1.4)
dotenv-rails (3.1.4)
dotenv (= 3.1.4)
dotenv (3.1.7)
dotenv-rails (3.1.7)
dotenv (= 3.1.7)
railties (>= 6.1)
down (5.4.2)
addressable (~> 2.8)
drb (2.2.1)
erubi (1.13.0)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
factory_bot (6.5.0)
@ -156,12 +156,12 @@ GEM
multi_xml (>= 0.5.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
importmap-rails (2.0.3)
importmap-rails (2.1.0)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.7.2)
irb (1.14.1)
io-console (0.8.0)
irb (1.14.3)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.7.4)
@ -180,7 +180,7 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.3)
logger (1.6.1)
logger (1.6.4)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -197,11 +197,11 @@ GEM
marcel (1.0.4)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (5.25.2)
minitest (5.25.4)
msgpack (1.7.3)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
net-imap (0.5.0)
net-imap (0.5.2)
date
net-protocol
net-pop (0.1.2)
@ -211,24 +211,24 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.16.7-aarch64-linux)
nokogiri (1.17.2-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.7-arm-linux)
nokogiri (1.17.2-arm-linux)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
nokogiri (1.17.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86-linux)
nokogiri (1.17.2-x86-linux)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin)
nokogiri (1.17.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.17.2-x86_64-linux)
racc (~> 1.4)
oj (3.16.7)
oj (3.16.8)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
optimist (3.2.0)
orm_adapter (0.5.0)
ostruct (0.6.0)
ostruct (0.6.1)
parallel (1.26.3)
parser (3.3.5.0)
ast (~> 2.4.1)
@ -246,7 +246,8 @@ GEM
pry (>= 0.13, < 0.15)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.2.0)
psych (5.2.2)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
@ -262,30 +263,30 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (7.2.2)
actioncable (= 7.2.2)
actionmailbox (= 7.2.2)
actionmailer (= 7.2.2)
actionpack (= 7.2.2)
actiontext (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activemodel (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
rails (8.0.1)
actioncable (= 8.0.1)
actionmailbox (= 8.0.1)
actionmailer (= 8.0.1)
actionpack (= 8.0.1)
actiontext (= 8.0.1)
actionview (= 8.0.1)
activejob (= 8.0.1)
activemodel (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
bundler (>= 1.15.0)
railties (= 7.2.2)
railties (= 8.0.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.1)
actionpack (= 8.0.1)
activesupport (= 8.0.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@ -293,14 +294,14 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rdoc (6.8.1)
rdoc (6.10.0)
psych (>= 4.0.0)
redis (5.3.0)
redis-client (>= 0.22.0)
redis-client (0.22.2)
redis-client (0.23.0)
connection_pool
regexp_parser (2.9.2)
reline (0.5.11)
reline (0.6.0)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@ -354,13 +355,13 @@ GEM
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (1.13.0)
securerandom (0.3.2)
securerandom (0.4.1)
shoulda-matchers (6.4.0)
activesupport (>= 5.2.0)
shrine (3.6.0)
content_disposition (~> 1.0)
down (~> 5.1)
sidekiq (7.3.6)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
@ -410,7 +411,8 @@ GEM
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
unicode-display_width (2.6.0)
useragent (0.16.10)
uri (1.0.2)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
webmock (3.24.0)
@ -456,7 +458,7 @@ DEPENDENCIES
pry-rails
puma
pundit
rails
rails (~> 8.0)
redis
rspec-rails
rswag-api

View file

@ -1 +1 @@
web: bin/rails server -p 3000 -b 0.0.0.0
web: bin/rails server -p 3000 -b ::

View file

@ -1,2 +1,2 @@
prometheus_exporter: bundle exec prometheus_exporter -b 0.0.0.0
web: bin/rails server -p 3000 -b 0.0.0.0
prometheus_exporter: bundle exec prometheus_exporter -b ANY
web: bin/rails server -p 3000 -b ::

View file

@ -33,6 +33,7 @@ Donate using crypto: [0x6bAd13667692632f1bF926cA9B421bEe7EaEB8D4](https://ethers
## ⚠️ Disclaimer
- 💔 **DO NOT UPDATE AUTOMATICALLY**: Read release notes before updating. Automatic updates may break your setup.
- 🛠️ **Under active development**: Expect frequent updates, bugs, and breaking changes.
- ❌ **Do not delete your original data** after importing into Dawarich.
- 📦 **Backup before updates**: Always [backup your data](https://dawarich.app/docs/tutorials/backup-and-restore) before upgrading.

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Api::V1::Countries::VisitedCitiesController < ApiController
before_action :validate_params
def index
start_at = DateTime.parse(params[:start_at]).to_i
end_at = DateTime.parse(params[:end_at]).to_i
points = current_api_user
.tracked_points
.where(timestamp: start_at..end_at)
render json: { data: CountriesAndCities.new(points).call }
end
private
def required_params
%i[start_at end_at]
end
end

View file

@ -4,6 +4,8 @@ class Api::V1::HealthController < ApiController
skip_before_action :authenticate_api_key
def index
response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!')
render json: { status: 'ok' }
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Api::V1::Points::TrackedMonthsController < ApiController
def index
render json: current_api_user.years_tracked
end
end

View file

@ -13,6 +13,26 @@ class ApiController < ApplicationController
end
def current_api_user
@current_api_user ||= User.find_by(api_key: params[:api_key])
@current_api_user ||= User.find_by(api_key:)
end
def api_key
params[:api_key] || request.headers['Authorization']&.split(' ')&.last
end
def validate_params
missing_params = required_params.select { |param| params[param].blank? }
if missing_params.any?
render json: {
error: "Missing required parameters: #{missing_params.join(', ')}"
}, status: :bad_request and return
end
params.permit(*required_params)
end
def required_params
[]
end
end

View file

@ -15,10 +15,15 @@ class NotificationsController < ApplicationController
def mark_as_read
current_user.notifications.unread.update_all(read_at: Time.zone.now)
redirect_to notifications_url, notice: 'All notifications marked as read.', status: :see_other
end
def destroy_all
current_user.notifications.destroy_all
redirect_to notifications_url, notice: 'All notifications where successfully destroyed.', status: :see_other
end
def destroy
@notification.destroy!
redirect_to notifications_url, notice: 'Notification was successfully destroyed.', status: :see_other

View file

@ -16,9 +16,27 @@ class StatsController < ApplicationController
end
def update
current_user.years_tracked.each do |year|
if params[:month] == 'all'
(1..12).each do |month|
Stats::CalculatingJob.perform_later(current_user.id, year, month)
Stats::CalculatingJob.perform_later(current_user.id, params[:year], month)
end
target = "the whole #{params[:year]}"
else
Stats::CalculatingJob.perform_later(current_user.id, params[:year], params[:month])
target = "#{Date::MONTHNAMES[params[:month].to_i]} of #{params[:year]}"
end
redirect_to stats_path, notice: "Stats for #{target} are being updated", status: :see_other
end
def update_all
current_user.years_tracked.each do |year|
year[:months].each do |month|
Stats::CalculatingJob.perform_later(
current_user.id, year[:year], Date::ABBR_MONTHNAMES.index(month)
)
end
end

View file

@ -32,6 +32,8 @@ export default class extends Controller {
settingsButtonAdded = false;
layerControl = null;
visitedCitiesCache = new Map();
trackedMonthsCache = null;
connect() {
console.log("Map controller connected");
@ -52,8 +54,8 @@ export default class extends Controller {
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
// Set the maximum bounds to prevent infinite scroll
var southWest = L.latLng(-90, -180);
var northEast = L.latLng(90, 180);
var southWest = L.latLng(-120, -210);
var northEast = L.latLng(120, 210);
var bounds = L.latLngBounds(southWest, northEast);
this.map.setMaxBounds(bounds);
@ -171,12 +173,37 @@ export default class extends Controller {
if (this.liveMapEnabled) {
this.setupSubscription();
}
// Add the toggle panel button
this.addTogglePanelButton();
// Check if we should open the panel based on localStorage or URL params
const urlParams = new URLSearchParams(window.location.search);
const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at');
// Always create the panel first
this.toggleRightPanel();
// Then hide it if it shouldn't be open
if (!isPanelOpen && !hasDateParams) {
const panel = document.querySelector('.leaflet-right-panel');
if (panel) {
panel.style.display = 'none';
localStorage.setItem('mapPanelOpen', 'false');
}
}
}
disconnect() {
if (this.handleDeleteClick) {
document.removeEventListener('click', this.handleDeleteClick);
}
// Store panel state before disconnecting
if (this.rightPanel) {
const finalState = document.querySelector('.leaflet-right-panel').style.display !== 'none' ? 'true' : 'false';
localStorage.setItem('mapPanelOpen', finalState);
}
this.map.remove();
}
@ -382,10 +409,14 @@ export default class extends Controller {
.then(data => {
// Remove the marker and update all layers
this.removeMarker(id);
let wasPolyLayerVisible = false;
// Explicitly remove old polylines layer from map
if (this.polylinesLayer) {
if (this.map.hasLayer(this.polylinesLayer)) {
wasPolyLayerVisible = true;
}
this.map.removeLayer(this.polylinesLayer);
}
// Create new polylines layer
@ -397,10 +428,12 @@ export default class extends Controller {
this.userSettings,
this.distanceUnit
);
// Add new polylines layer to map and to layer control
this.polylinesLayer.addTo(this.map);
if (wasPolyLayerVisible) {
// Add new polylines layer to map and to layer control
this.polylinesLayer.addTo(this.map);
} else {
this.map.removeLayer(this.polylinesLayer);
}
// Update the layer control
if (this.layerControl) {
this.map.removeControl(this.layerControl);
@ -898,8 +931,385 @@ export default class extends Controller {
${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
</div>
`;
marker.bindPopup(popupContent);
marker.bindPopup(popupContent, { autoClose: false });
this.photoMarkers.addLayer(marker);
}
addTogglePanelButton() {
const TogglePanelControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'toggle-panel-button');
button.innerHTML = '📅';
button.style.backgroundColor = 'white';
button.style.width = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Toggle panel on button click
L.DomEvent.on(button, 'click', () => {
this.toggleRightPanel();
});
return button;
}
});
// Add the control to the map
this.map.addControl(new TogglePanelControl({ position: 'topright' }));
}
toggleRightPanel() {
if (this.rightPanel) {
const panel = document.querySelector('.leaflet-right-panel');
if (panel) {
if (panel.style.display === 'none') {
panel.style.display = 'block';
localStorage.setItem('mapPanelOpen', 'true');
} else {
panel.style.display = 'none';
localStorage.setItem('mapPanelOpen', 'false');
}
return;
}
}
this.rightPanel = L.control({ position: 'topright' });
this.rightPanel.onAdd = () => {
const div = L.DomUtil.create('div', 'leaflet-right-panel');
const allMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Get current date from URL query parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at');
const currentYear = startDate
? new Date(startDate).getFullYear().toString()
: new Date().getFullYear().toString();
const currentMonth = startDate
? allMonths[new Date(startDate).getMonth()]
: allMonths[new Date().getMonth()];
// Initially create select with loading state and current year if available
div.innerHTML = `
<div class="panel-content">
<div id='years-nav'>
<div class="flex gap-2 mb-4">
<select id="year-select" class="select select-bordered w-1/2 max-w-xs">
${currentYear
? `<option value="${currentYear}" selected>${currentYear}</option>`
: '<option disabled selected>Loading years...</option>'}
</select>
<a href="${this.getWholeYearLink()}"
id="whole-year-link"
class="btn btn-default"
style="color: rgb(116 128 255) !important;">
Whole year
</a>
</div>
<div class='grid grid-cols-3 gap-3' id="months-grid">
${allMonths.map(month => `
<a href="#"
class="btn btn-primary disabled ${month === currentMonth ? 'btn-active' : ''}"
data-month-name="${month}"
style="pointer-events: none; opacity: 0.6; color: rgb(116 128 255) !important;">
<span class="loading loading-dots loading-md"></span>
</a>
`).join('')}
</div>
</div>
</div>
`;
this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths);
div.style.backgroundColor = 'white';
div.style.padding = '10px';
div.style.border = '1px solid #ccc';
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
div.style.marginRight = '10px';
div.style.marginTop = '10px';
div.style.width = '300px';
div.style.maxHeight = '80vh';
div.style.overflowY = 'auto';
L.DomEvent.disableClickPropagation(div);
// Add container for visited cities
div.innerHTML += `
<div id="visited-cities-container" class="mt-4">
<h3 class="text-lg font-bold mb-2">Visited cities</h3>
<div id="visited-cities-list" class="space-y-2"
style="max-height: 300px; overflow-y: auto; overflow-x: auto; padding-right: 10px;">
<p class="text-gray-500">Loading visited places...</p>
</div>
</div>
`;
// Prevent map zoom when scrolling the cities list
const citiesList = div.querySelector('#visited-cities-list');
L.DomEvent.disableScrollPropagation(citiesList);
// Fetch visited cities when panel is first created
this.fetchAndDisplayVisitedCities();
// Set initial display style based on localStorage
const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
div.style.display = isPanelOpen ? 'block' : 'none';
return div;
};
this.map.addControl(this.rightPanel);
}
async fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths) {
try {
let yearsData;
// Check cache first
if (this.trackedMonthsCache) {
yearsData = this.trackedMonthsCache;
} else {
const response = await fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
yearsData = await response.json();
// Store in cache
this.trackedMonthsCache = yearsData;
}
const yearSelect = document.getElementById('year-select');
if (!Array.isArray(yearsData) || yearsData.length === 0) {
yearSelect.innerHTML = '<option disabled selected>No data available</option>';
return;
}
// Check if the current year exists in the API response
const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear);
const options = yearsData
.filter(yearData => yearData && yearData.year)
.map(yearData => {
const months = Array.isArray(yearData.months) ? yearData.months : [];
const isCurrentYear = yearData.year.toString() === currentYear;
return `
<option value="${yearData.year}"
data-months='${JSON.stringify(months)}'
${isCurrentYear ? 'selected' : ''}>
${yearData.year}
</option>
`;
})
.join('');
yearSelect.innerHTML = `
<option disabled>Select year</option>
${options}
`;
const updateMonthLinks = (selectedYear, availableMonths) => {
// Get current date from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date();
const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date();
allMonths.forEach((month, index) => {
const monthLink = div.querySelector(`a[data-month-name="${month}"]`);
if (!monthLink) return;
// Update the content to show the month name instead of loading dots
monthLink.innerHTML = month;
// Check if this month falls within the selected date range
const isSelected = startDate && endDate &&
selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year
isMonthInRange(index, startDate, endDate, parseInt(selectedYear));
if (availableMonths.includes(month)) {
monthLink.classList.remove('disabled');
monthLink.style.pointerEvents = 'auto';
monthLink.style.opacity = '1';
// Update the active state based on selection
if (isSelected) {
monthLink.classList.add('btn-active', 'btn-primary');
} else {
monthLink.classList.remove('btn-active', 'btn-primary');
}
const monthNum = (index + 1).toString().padStart(2, '0');
const startDate = `${selectedYear}-${monthNum}-01T00:00`;
const lastDay = new Date(selectedYear, index + 1, 0).getDate();
const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`;
const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`;
monthLink.setAttribute('href', href);
} else {
monthLink.classList.add('disabled');
monthLink.classList.remove('btn-active', 'btn-primary');
monthLink.style.pointerEvents = 'none';
monthLink.style.opacity = '0.6';
monthLink.setAttribute('href', '#');
}
});
};
// Helper function to check if a month falls within a date range
const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => {
// Create date objects for the first and last day of the month in the selected year
const monthStart = new Date(selectedYear, monthIndex, 1);
const monthEnd = new Date(selectedYear, monthIndex + 1, 0);
// Check if any part of the month overlaps with the selected date range
return monthStart <= endDate && monthEnd >= startDate;
};
yearSelect.addEventListener('change', (event) => {
const selectedOption = event.target.selectedOptions[0];
const selectedYear = selectedOption.value;
const availableMonths = JSON.parse(selectedOption.dataset.months || '[]');
// Update whole year link with selected year
const wholeYearLink = document.getElementById('whole-year-link');
const startDate = `${selectedYear}-01-01T00:00`;
const endDate = `${selectedYear}-12-31T23:59`;
const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`;
wholeYearLink.setAttribute('href', href);
updateMonthLinks(selectedYear, availableMonths);
});
// If we have a current year, set it and update month links
if (currentYear && currentYearData) {
yearSelect.value = currentYear;
updateMonthLinks(currentYear, currentYearData.months);
}
} catch (error) {
const yearSelect = document.getElementById('year-select');
yearSelect.innerHTML = '<option disabled selected>Error loading years</option>';
console.error('Error fetching tracked months:', error);
}
}
chunk(array, size) {
const chunked = [];
for (let i = 0; i < array.length; i += size) {
chunked.push(array.slice(i, i + size));
}
return chunked;
}
getWholeYearLink() {
// First try to get year from URL parameters
const urlParams = new URLSearchParams(window.location.search);
let year;
if (urlParams.has('start_at')) {
year = new Date(urlParams.get('start_at')).getFullYear();
} else {
// If no URL params, try to get year from start_at input
const startAtInput = document.querySelector('input#start_at');
if (startAtInput && startAtInput.value) {
year = new Date(startAtInput.value).getFullYear();
} else {
// If no input value, use current year
year = new Date().getFullYear();
}
}
const startDate = `${year}-01-01T00:00`;
const endDate = `${year}-12-31T23:59`;
return `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`;
}
async fetchAndDisplayVisitedCities() {
const urlParams = new URLSearchParams(window.location.search);
const startAt = urlParams.get('start_at') || new Date().toISOString();
const endAt = urlParams.get('end_at') || new Date().toISOString();
// Create a cache key from the date range
const cacheKey = `${startAt}-${endAt}`;
// Check if we have cached data for this date range
if (this.visitedCitiesCache.has(cacheKey)) {
this.displayVisitedCities(this.visitedCitiesCache.get(cacheKey));
return;
}
try {
const response = await fetch(`/api/v1/countries/visited_cities?api_key=${this.apiKey}&start_at=${startAt}&end_at=${endAt}`, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
// Cache the results
this.visitedCitiesCache.set(cacheKey, data.data);
this.displayVisitedCities(data.data);
} catch (error) {
console.error('Error fetching visited cities:', error);
const container = document.getElementById('visited-cities-list');
if (container) {
container.innerHTML = '<p class="text-red-500">Error loading visited places</p>';
}
}
}
displayVisitedCities(citiesData) {
const container = document.getElementById('visited-cities-list');
if (!container) return;
if (!citiesData || citiesData.length === 0) {
container.innerHTML = '<p class="text-gray-500">No places visited during this period</p>';
return;
}
const html = citiesData.map(country => `
<div class="mb-4" style="min-width: min-content;">
<h4 class="font-bold text-md">${country.country}</h4>
<ul class="ml-4 space-y-1">
${country.cities.map(city => `
<li class="text-sm whitespace-nowrap">
${city.city}
<span class="text-gray-500">
(${new Date(city.timestamp * 1000).toLocaleDateString()})
</span>
</li>
`).join('')}
</ul>
</div>
`).join('');
container.innerHTML = html;
}
formatDuration(seconds) {
const days = Math.floor(seconds / (24 * 60 * 60));
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
if (days > 0) {
return `${days}d ${hours}h`;
}
return `${hours}h`;
}
}

View file

@ -161,6 +161,7 @@ export function countryCodesMap() {
"Niue": "NU",
"Norfolk Island": "NF",
"Northern Mariana Islands": "MP",
"North Macedonia": "MK",
"Norway": "NO",
"Oman": "OM",
"Pakistan": "PK",

View file

@ -55,7 +55,15 @@ export function minutesToDaysHoursMinutes(minutes) {
export function formatDate(timestamp, timezone) {
const date = new Date(timestamp * 1000);
return date.toLocaleString("en-GB", { timeZone: timezone });
let locale;
if (navigator.languages !== undefined) {
locale = navigator.languages[0];
} else if (navigator.language) {
locale = navigator.language;
} else {
locale = 'en-GB';
}
return date.toLocaleString(locale, { timeZone: timezone });
}
export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {

View file

@ -9,7 +9,7 @@ export function createMarkersArray(markersData, userSettings) {
const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit);
let markerColor = marker[5] < 0 ? "orange" : "blue";
return L.circleMarker([lat, lon], { radius: 4, color: markerColor }).bindPopup(popupContent);
return L.circleMarker([lat, lon], { radius: 4, color: markerColor }).bindPopup(popupContent, { autoClose: false });
});
}
}

View file

@ -1,3 +1,4 @@
import { formatDate } from "../maps/helpers";
import { formatDistance } from "../maps/helpers";
import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
@ -12,8 +13,8 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: userSettings.timezone });
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: userSettings.timezone });
const firstTimestamp = formatDate(startPoint[4], userSettings.timezone);
const lastTimestamp = formatDate(endPoint[4], userSettings.timezone);
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
const timeOnRoute = minutesToDaysHoursMinutes(minutes);

View file

@ -15,6 +15,7 @@ export function createPopupContent(marker, timezone, distanceUnit) {
<strong>Altitude:</strong> ${marker[3]}m<br>
<strong>Velocity:</strong> ${marker[5]}km/h<br>
<strong>Battery:</strong> ${marker[2]}%<br>
<strong>Id:</strong> ${marker[6]}<br>
<a href="#" data-id="${marker[6]}" class="delete-point">[Delete]</a>
`;
}

9
app/jobs/cache/cleaning_job.rb vendored Normal file
View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Cache::CleaningJob < ApplicationJob
queue_as :default
def perform
Cache::Clean.call
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class DataMigrations::SetReverseGeocodedAtForPointsJob < ApplicationJob
queue_as :default
def perform
timestamp = Time.current
Point.where.not(geodata: {})
.where(reverse_geocoded_at: nil)
.in_batches(of: 10_000) do |relation|
# rubocop:disable Rails/SkipsModelValidations
relation.update_all(reverse_geocoded_at: timestamp)
# rubocop:enable Rails/SkipsModelValidations
end
end
end

View file

@ -6,18 +6,21 @@ class Stats::CalculatingJob < ApplicationJob
def perform(user_id, year, month)
Stats::CalculateMonth.new(user_id, year, month).call
create_stats_updated_notification(user_id)
create_stats_updated_notification(user_id, year, month)
rescue StandardError => e
create_stats_update_failed_notification(user_id, e)
end
private
def create_stats_updated_notification(user_id)
def create_stats_updated_notification(user_id, year, month)
user = User.find(user_id)
Notifications::Create.new(
user:, kind: :info, title: 'Stats updated', content: 'Stats updated'
user:,
kind: :info,
title: "Stats updated for #{Date::MONTHNAMES[month.to_i]} of #{year}",
content: "Stats updated for #{Date::MONTHNAMES[month.to_i]} of #{year}"
).call
end

View file

@ -21,20 +21,6 @@ class Stat < ApplicationRecord
end
end
def self.year_cities_and_countries(year, user)
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 { _1[:cities].count }
}
end
def points
user.tracked_points
.without_raw_data

View file

@ -1,8 +1,6 @@
# frozen_string_literal: true
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
@ -70,10 +68,13 @@ class User < ApplicationRecord
Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
tracked_points
.pluck(:timestamp)
.map { |ts| Time.zone.at(ts).year }
.uniq
.sort
.reverse
.map { |ts| Time.zone.at(ts) }
.group_by(&:year)
.transform_values do |dates|
dates.map { |date| date.strftime('%b') }.uniq.sort
end
.map { |year, months| { year: year, months: months } }
.sort_by { |entry| -entry[:year] } # Sort in descending order
end
end

24
app/services/cache/clean.rb vendored Normal file
View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Cache::Clean
class << self
def call
Rails.logger.info('Cleaning cache...')
delete_version_cache
delete_years_tracked_cache
Rails.logger.info('Cache cleaned')
end
private
def delete_version_cache
Rails.cache.delete(CheckAppVersion::VERSION_CACHE_KEY)
end
def delete_years_tracked_cache
User.find_each do |user|
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
end
end
end
end

View file

@ -1,56 +1,54 @@
# frozen_string_literal: true
class CountriesAndCities
CountryData = Struct.new(:country, :cities, keyword_init: true)
CityData = Struct.new(:city, :points, :timestamp, :stayed_for, keyword_init: true)
def initialize(points)
@points = points
end
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)
points
.reject { |point| point.country.nil? || point.city.nil? }
.group_by(&:country)
.transform_values { |country_points| process_country_points(country_points) }
.map { |country, cities| CountryData.new(country: country, cities: cities) }
end
private
attr_reader :points
def group_points
points.group_by(&:country)
def process_country_points(country_points)
country_points
.group_by(&:city)
.transform_values { |city_points| create_city_data_if_valid(city_points) }
.values
.compact
end
def map_with_cities(grouped_records)
grouped_records.transform_values do |grouped_points|
grouped_points
.pluck(:city, :timestamp) # Extract city and timestamp
.delete_if { _1.first.nil? } # Remove records without city
.group_by { |city, _| city } # Group by city
.transform_values do |cities|
{
points: cities.count,
last_timestamp: cities.map(&:last).max, # Get the maximum timestamp
stayed_for: ((cities.map(&:last).max - cities.map(&:last).min).to_i / 60) # Calculate the time stayed in minutes
}
end
end
def create_city_data_if_valid(city_points)
timestamps = city_points.pluck(:timestamp)
duration = calculate_duration_in_minutes(timestamps)
city = city_points.first.city
points_count = city_points.size
build_city_data(city, points_count, timestamps, duration)
end
def filter_cities(mapped_with_cities)
# Remove cities where user stayed for less than 1 hour
mapped_with_cities.transform_values do |cities|
cities.reject { |_, data| data[:stayed_for] < MIN_MINUTES_SPENT_IN_CITY }
end
def build_city_data(city, points_count, timestamps, duration)
return nil if duration < ::MIN_MINUTES_SPENT_IN_CITY
CityData.new(
city: city,
points: points_count,
timestamp: timestamps.max,
stayed_for: duration
)
end
def normalize_result(hash)
hash.map do |country, cities|
{
country:,
cities: cities.map do |city, data|
{ city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for] }
end
}
end
def calculate_duration_in_minutes(timestamps)
((timestamps.max - timestamps.min).to_i / 60)
end
end

View file

@ -12,6 +12,12 @@ class ReverseGeocoding::Points::FetchData
def call
return if point.reverse_geocoded?
update_point_with_geocoding_data
end
private
def update_point_with_geocoding_data
response = Geocoder.search([point.latitude, point.longitude]).first
return if response.blank? || response.data['error'].present?

View file

@ -3,8 +3,8 @@
class Stats::CalculateMonth
def initialize(user_id, year, month)
@user = User.find(user_id)
@year = year
@month = month
@year = year.to_i
@month = month.to_i
end
def call

View file

@ -1,7 +1,7 @@
<% content_for :title, 'Map' %>
<div class="flex flex-col lg:flex-row lg:space-x-4 my-5 w-full">
<div class='w-full lg:w-5/6'>
<div class='w-full'>
<div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
@ -58,10 +58,6 @@
</div>
</div>
</div>
<div class='w-full lg:w-1/6 mt-8 lg:mt-0 mx-auto'>
<%= render 'shared/right_sidebar' %>
</div>
</div>
<%= render 'map/settings_modals' %>

View file

@ -4,7 +4,10 @@
<h1 class="font-bold text-4xl mb-4">Notifications</h1>
<div class="flex items-center justify-center mb-4">
<% if @notifications.unread.any? %>
<%= link_to "Mark all as read", mark_notifications_as_read_path, method: :post, data: { turbo_method: :post }, class: "btn btn-sm btn-primary" %>
<%= link_to "Mark all as read", mark_notifications_as_read_path, method: :post, data: { turbo_method: :post }, class: "btn btn-sm btn-primary" %>&nbsp;
<% end %>
<% if @notifications.any? %>
<%= link_to "Delete all", delete_all_notifications_path, method: :post, data: { turbo_method: :post, turbo_confirm: 'Are you sure you want to delete all notifications?' }, class: "btn btn-sm btn-warning" %>
<% end %>
</div>
<div class="mb-4">

View file

@ -1,64 +0,0 @@
<%= sidebar_distance(@distance) %> <%= sidebar_points(@points) %>
<div id='years-nav'>
<div class="dropdown">
<div tabindex="0" role="button" class="btn">Select year</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<% current_user.years_tracked.each do |year| %>
<li><%= link_to year, map_url(year_timespan(year).merge(year: year, import_id: params[:import_id])) %></li>
<% end %>
</ul>
</div>
<% @years.each do |year| %>
<h3 class='text-xl'>
<%= year %>
</h3>
<div class='grid grid-cols-3 gap-3'>
<% (1..12).to_a.each_slice(3) do |months| %>
<% months.each do |month_number| %>
<% if past?(year, month_number) && points_exist?(year, month_number, current_user) %>
<%= link_to Date::ABBR_MONTHNAMES[month_number], map_url(timespan(month_number, year).merge(import_id: params[:import_id])), class: 'btn btn-default' %>
<% else %>
<div class='btn btn-disabled'><%= Date::ABBR_MONTHNAMES[month_number] %></div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% 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? %>
<h2 class="text-lg font-semibold mt-5">
<%= country[:country] %> (<%= country[:cities].count %> cities)
</h2>
<ul class="timeline timeline-vertical">
<% country[:cities].each do |city| %>
<li>
<hr />
<div class="timeline-start"><%= link_to_date(city[:timestamp]) %></div>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
</div>
<div class="timeline-end timeline-box"><%= city[:city] %></div>
<hr />
</li>
<% end %>
</ul>
<% end %>
<% end %>

View file

@ -1,10 +1,16 @@
<div id="<%= dom_id stat %>" class="card w-full bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
<%= "#{Date::MONTHNAMES[stat.month]} of #{stat.year}" %>
<% end %>
</h2>
<div class="flex justify-between items-center">
<h2 class="card-title">
<%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
<%= Date::MONTHNAMES[stat.month] %>
<% end %>
</h2>
<div class="flex items-center gap-2">
<%= link_to '[Update]', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
</div>
</div>
<p><%= stat.distance %><%= DISTANCE_UNIT %></p>
<% if REVERSE_GEOCODING_ENABLED %>
<div class="card-actions justify-end">

View file

@ -21,15 +21,18 @@
<% end %>
</div>
<%= link_to 'Update stats', stats_path, data: { 'turbo-method' => :post }, class: 'btn btn-primary mt-5' %>
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
<div class="mt-5 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 p-4">
<div class="mt-6 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
<% @stats.each do |year, stats| %>
<div class="card w-full bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-<%= header_colors.sample %>">
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
<h2 class="card-title justify-between text-<%= header_colors.sample %>">
<div>
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</div>
<%= link_to '[Update]', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
</h2>
<p>
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>

8
bin/rubocop Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
# explicit rubocop config increases performance slightly while avoiding config confusion.
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
load Gem.bin_path("rubocop", "rubocop")

View file

@ -1,8 +1,8 @@
#!/usr/bin/env ruby
require "fileutils"
# path to your application root.
APP_ROOT = File.expand_path("..", __dir__)
APP_NAME = "dawarich"
def system!(*args)
system(*args, exception: true)
@ -30,4 +30,8 @@ FileUtils.chdir APP_ROOT do
puts "\n== Restarting application server =="
system! "bin/rails restart"
# puts "\n== Configuring puma-dev =="
# system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}"
# system "curl -Is https://#{APP_NAME}.test/up | head -n 1"
end

View file

@ -11,7 +11,7 @@ Bundler.require(*Rails.groups)
module Dawarich
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
config.load_defaults 8.0
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.

View file

@ -6,6 +6,8 @@ require_relative 'application'
# Initialize the Rails application.
Rails.application.initialize!
# Clear the cache of the application version
# Clear the cache
Cache::CleaningJob.perform_later
Rails.cache.delete(CheckAppVersion::VERSION_CACHE_KEY)
# Preheat the cache
Cache::PreheatingJob.perform_later

View file

@ -19,6 +19,11 @@ Rails.application.configure do
# Enable server timing
config.server_timing = true
# Info include generic and useful information about system operation, but avoids logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII). If you
# want to log everything, leave the level on "debug".
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'debug')
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp/caching-dev.txt').exist?
@ -74,7 +79,7 @@ Rails.application.configure do
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
config.action_view.annotate_rendered_view_with_filenames = true
# Uncomment if you wish to allow Action Cable access from any origin.
# config.action_cable.disable_request_forgery_protection = true

View file

@ -1,4 +1,6 @@
require "active_support/core_ext/integer/time"
# frozen_string_literal: true
require 'active_support/core_ext/integer/time'
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@ -39,6 +41,8 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
config.silence_healthcheck_path = '/api/v1/health'
# Mount Action Cable outside main process or domain.
# config.action_cable.mount_path = nil
# config.action_cable.url = "wss://example.com/cable"
@ -52,17 +56,17 @@ Rails.application.configure do
config.force_ssl = true
# Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT)
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
config.logger = ActiveSupport::Logger.new($stdout)
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# Prepend all log lines with the following tags.
config.log_tags = [ :request_id ]
config.log_tags = [:request_id]
# Info include generic and useful information about system operation, but avoids logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII). If you
# want to log everything, set the level to "debug".
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
# Use a different cache store in production.
# config.cache_store = :mem_cache_store

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'active_support/core_ext/integer/time'
# The test environment is used exclusively to run your application's
@ -24,11 +26,11 @@ Rails.application.configure do
}
# Show full error reports and disable caching.
config.consider_all_requests_local = true
config.consider_all_requests_local = true
config.action_controller.perform_caching = false
config.cache_store = :null_store
# Raise exceptions instead of rendering exception templates.
# Render exception templates for rescuable exceptions and raise for other exceptions.
config.action_dispatch.show_exceptions = :rescuable
# Disable request forgery protection in test environment.
@ -37,6 +39,8 @@ Rails.application.configure do
# Store uploaded files on the local file system in a temporary directory.
config.active_storage.service = :test
# Disable caching for Action Mailer templates even if Action Controller
# caching is enabled.
config.action_mailer.perform_caching = false
# Tell Action Mailer not to deliver emails to the real world.
@ -44,6 +48,10 @@ Rails.application.configure do
# ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test
# Unlike controllers, the mailer instance doesn't have any context about the
# incoming request so you'll need to provide the :host parameter yourself.
config.action_mailer.default_url_options = { host: 'www.example.com' }
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
@ -59,6 +67,6 @@ Rails.application.configure do
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
end

View file

@ -1,12 +1,9 @@
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Version of your assets, change this if you want to expire all your assets.
Rails.application.config.assets.version = "1.0"
Rails.application.config.assets.version = '1.0'
# Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path
# Precompile additional assets.
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
# Rails.application.config.assets.precompile += %w( admin.js admin.css )

View file

@ -1,8 +1,10 @@
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
Rails.application.config.filter_parameters += %i[
passw email secret token _key crypt salt certificate otp ssn cvv cvc latitude longitude lat lng
]

View file

@ -17,4 +17,6 @@ if defined?(PHOTON_API_HOST)
settings[:photon] = { use_https: PHOTON_API_USE_HTTPS, host: PHOTON_API_HOST }
end
settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if defined?(PHOTON_API_KEY)
Geocoder.configure(settings)

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Suppress warnings about nil deprecation
# https://github.com/jnunemaker/httparty/issues/568#issuecomment-1450473603
HTTParty::Response.class_eval do
def warn_about_nil_deprecation; end
end

View file

@ -1,223 +0,0 @@
# Be sure to restart your server when you modify this file.
#
# This file eases your Rails 7.1 framework defaults upgrade.
#
# Uncomment each configuration one by one to switch to the new default.
# Once your application is ready to run with all new defaults, you can remove
# this file and set the `config.load_defaults` to `7.1`.
#
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
# No longer add autoloaded paths into `$LOAD_PATH`. This means that you won't be able
# to manually require files that are managed by the autoloader, which you shouldn't do anyway.
# This will reduce the size of the load path, making `require` faster if you don't use bootsnap, or reduce the size
# of the bootsnap cache if you use it.
# Rails.application.config.add_autoload_paths_to_load_path = false
# Remove the default X-Download-Options headers since it is used only by Internet Explorer.
# If you need to support Internet Explorer, add back `"X-Download-Options" => "noopen"`.
# Rails.application.config.action_dispatch.default_headers = {
# "X-Frame-Options" => "SAMEORIGIN",
# "X-XSS-Protection" => "0",
# "X-Content-Type-Options" => "nosniff",
# "X-Permitted-Cross-Domain-Policies" => "none",
# "Referrer-Policy" => "strict-origin-when-cross-origin"
# }
# Do not treat an `ActionController::Parameters` instance
# as equal to an equivalent `Hash` by default.
# Rails.application.config.action_controller.allow_deprecated_parameters_hash_equality = false
# Active Record Encryption now uses SHA-256 as its hash digest algorithm. Important: If you have
# data encrypted with previous Rails versions, there are two scenarios to consider:
#
# 1. If you have +config.active_support.key_generator_hash_digest_class+ configured as SHA1 (the default
# before Rails 7.0), you need to configure SHA-1 for Active Record Encryption too:
# Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA1
# 2. If you have +config.active_support.key_generator_hash_digest_class+ configured as SHA256 (the new default
# in 7.0), then you need to configure SHA-256 for Active Record Encryption:
# Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256
#
# If you don't currently have data encrypted with Active Record encryption, you can disable this setting to
# configure the default behavior starting 7.1+:
# Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = false
# No longer run after_commit callbacks on the first of multiple Active Record
# instances to save changes to the same database row within a transaction.
# Instead, run these callbacks on the instance most likely to have internal
# state which matches what was committed to the database, typically the last
# instance to save.
# Rails.application.config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction = false
# Configures SQLite with a strict strings mode, which disables double-quoted string literals.
#
# SQLite has some quirks around double-quoted string literals.
# It first tries to consider double-quoted strings as identifier names, but if they don't exist
# it then considers them as string literals. Because of this, typos can silently go unnoticed.
# For example, it is possible to create an index for a non existing column.
# See https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted for more details.
# Rails.application.config.active_record.sqlite3_adapter_strict_strings_by_default = true
# Disable deprecated singular associations names
# Rails.application.config.active_record.allow_deprecated_singular_associations_name = false
# Enable the Active Job `BigDecimal` argument serializer, which guarantees
# roundtripping. Without this serializer, some queue adapters may serialize
# `BigDecimal` arguments as simple (non-roundtrippable) strings.
#
# When deploying an application with multiple replicas, old (pre-Rails 7.1)
# replicas will not be able to deserialize `BigDecimal` arguments from this
# serializer. Therefore, this setting should only be enabled after all replicas
# have been successfully upgraded to Rails 7.1.
# Rails.application.config.active_job.use_big_decimal_serializer = true
# Specify if an `ArgumentError` should be raised if `Rails.cache` `fetch` or
# `write` are given an invalid `expires_at` or `expires_in` time.
# Options are `true`, and `false`. If `false`, the exception will be reported
# as `handled` and logged instead.
# Rails.application.config.active_support.raise_on_invalid_cache_expiration_time = true
# Specify whether Query Logs will format tags using the SQLCommenter format
# (https://open-telemetry.github.io/opentelemetry-sqlcommenter/), or using the legacy format.
# Options are `:legacy` and `:sqlcommenter`.
# Rails.application.config.active_record.query_log_tags_format = :sqlcommenter
# Specify the default serializer used by `MessageEncryptor` and `MessageVerifier`
# instances.
#
# The legacy default is `:marshal`, which is a potential vector for
# deserialization attacks in cases where a message signing secret has been
# leaked.
#
# In Rails 7.1, the new default is `:json_allow_marshal` which serializes and
# deserializes with `ActiveSupport::JSON`, but can fall back to deserializing
# with `Marshal` so that legacy messages can still be read.
#
# In Rails 7.2, the default will become `:json` which serializes and
# deserializes with `ActiveSupport::JSON` only.
#
# Alternatively, you can choose `:message_pack` or `:message_pack_allow_marshal`,
# which serialize with `ActiveSupport::MessagePack`. `ActiveSupport::MessagePack`
# can roundtrip some Ruby types that are not supported by JSON, and may provide
# improved performance, but it requires the `msgpack` gem.
#
# For more information, see
# https://guides.rubyonrails.org/v7.1/configuring.html#config-active-support-message-serializer
#
# If you are performing a rolling deploy of a Rails 7.1 upgrade, wherein servers
# that have not yet been upgraded must be able to read messages from upgraded
# servers, first deploy without changing the serializer, then set the serializer
# in a subsequent deploy.
# Rails.application.config.active_support.message_serializer = :json_allow_marshal
# Enable a performance optimization that serializes message data and metadata
# together. This changes the message format, so messages serialized this way
# cannot be read by older versions of Rails. However, messages that use the old
# format can still be read, regardless of whether this optimization is enabled.
#
# To perform a rolling deploy of a Rails 7.1 upgrade, wherein servers that have
# not yet been upgraded must be able to read messages from upgraded servers,
# leave this optimization off on the first deploy, then enable it on a
# subsequent deploy.
# Rails.application.config.active_support.use_message_serializer_for_metadata = true
# Set the maximum size for Rails log files.
#
# `config.load_defaults 7.1` does not set this value for environments other than
# development and test.
#
# if Rails.env.local?
# Rails.application.config.log_file_size = 100 * 1024 * 1024
# end
# Enable raising on assignment to attr_readonly attributes. The previous
# behavior would allow assignment but silently not persist changes to the
# database.
# Rails.application.config.active_record.raise_on_assign_to_attr_readonly = true
# Enable validating only parent-related columns for presence when the parent is mandatory.
# The previous behavior was to validate the presence of the parent record, which performed an extra query
# to get the parent every time the child record was updated, even when parent has not changed.
# Rails.application.config.active_record.belongs_to_required_validates_foreign_key = false
# Enable precompilation of `config.filter_parameters`. Precompilation can
# improve filtering performance, depending on the quantity and types of filters.
# Rails.application.config.precompile_filter_parameters = true
# Enable before_committed! callbacks on all enrolled records in a transaction.
# The previous behavior was to only run the callbacks on the first copy of a record
# if there were multiple copies of the same record enrolled in the transaction.
# Rails.application.config.active_record.before_committed_on_all_records = true
# Disable automatic column serialization into YAML.
# To keep the historic behavior, you can set it to `YAML`, however it is
# recommended to explicitly define the serialization method for each column
# rather than to rely on a global default.
# Rails.application.config.active_record.default_column_serializer = nil
# Enable a performance optimization that serializes Active Record models
# in a faster and more compact way.
#
# To perform a rolling deploy of a Rails 7.1 upgrade, wherein servers that have
# not yet been upgraded must be able to read caches from upgraded servers,
# leave this optimization off on the first deploy, then enable it on a
# subsequent deploy.
# Rails.application.config.active_record.marshalling_format_version = 7.1
# Run `after_commit` and `after_*_commit` callbacks in the order they are defined in a model.
# This matches the behaviour of all other callbacks.
# In previous versions of Rails, they ran in the inverse order.
# Rails.application.config.active_record.run_after_transaction_callbacks_in_order_defined = true
# Whether a `transaction` block is committed or rolled back when exited via `return`, `break` or `throw`.
#
# Rails.application.config.active_record.commit_transaction_on_non_local_return = true
# Controls when to generate a value for <tt>has_secure_token</tt> declarations.
#
# Rails.application.config.active_record.generate_secure_token_on = :initialize
# ** Please read carefully, this must be configured in config/application.rb **
# Change the format of the cache entry.
# Changing this default means that all new cache entries added to the cache
# will have a different format that is not supported by Rails 7.0
# applications.
# Only change this value after your application is fully deployed to Rails 7.1
# and you have no plans to rollback.
# When you're ready to change format, add this to `config/application.rb` (NOT
# this file):
# config.active_support.cache_format_version = 7.1
# Configure Action View to use HTML5 standards-compliant sanitizers when they are supported on your
# platform.
#
# `Rails::HTML::Sanitizer.best_supported_vendor` will cause Action View to use HTML5-compliant
# sanitizers if they are supported, else fall back to HTML4 sanitizers.
#
# In previous versions of Rails, Action View always used `Rails::HTML4::Sanitizer` as its vendor.
#
# Rails.application.config.action_view.sanitizer_vendor = Rails::HTML::Sanitizer.best_supported_vendor
# Configure Action Text to use an HTML5 standards-compliant sanitizer when it is supported on your
# platform.
#
# `Rails::HTML::Sanitizer.best_supported_vendor` will cause Action Text to use HTML5-compliant
# sanitizers if they are supported, else fall back to HTML4 sanitizers.
#
# In previous versions of Rails, Action Text always used `Rails::HTML4::Sanitizer` as its vendor.
#
# Rails.application.config.action_text.sanitizer_vendor = Rails::HTML::Sanitizer.best_supported_vendor
# Configure the log level used by the DebugExceptions middleware when logging
# uncaught exceptions during requests
# Rails.application.config.action_dispatch.debug_exception_log_level = :error
# Configure the test helpers in Action View, Action Dispatch, and rails-dom-testing to use HTML5
# parsers.
#
# Nokogiri::HTML5 isn't supported on JRuby, so JRuby applications must set this to :html4.
#
# In previous versions of Rails, these test helpers always used an HTML4 parser.
#
# Rails.application.config.dom_testing_default_html_version = :html5

View file

@ -0,0 +1,30 @@
# Be sure to restart your server when you modify this file.
#
# This file eases your Rails 8.0 framework defaults upgrade.
#
# Uncomment each configuration one by one to switch to the new default.
# Once your application is ready to run with all new defaults, you can remove
# this file and set the `config.load_defaults` to `8.0`.
#
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
###
# Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone.
# If set to `:zone`, `to_time` methods will use the timezone of their receivers.
# If set to `:offset`, `to_time` methods will use the UTC offset.
# If `false`, `to_time` methods will convert to the local system UTC offset instead.
#++
# Rails.application.config.active_support.to_time_preserves_timezone = :zone
###
# When both `If-Modified-Since` and `If-None-Match` are provided by the client
# only consider `If-None-Match` as specified by RFC 7232 Section 6.
# If set to `false` both conditions need to be satisfied.
#++
# Rails.application.config.action_dispatch.strict_freshness = true
###
# Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks.
#++
# Regexp.timeout = 1

View file

@ -6,9 +6,8 @@
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
#
max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
threads min_threads_count, max_threads_count
threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
threads threads_count, threads_count
# Specifies the `worker_timeout` threshold that Puma will use to wait before
# terminating a worker in development environments.
@ -50,7 +49,7 @@ if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'
before_fork do
PrometheusExporter::Client.default = PrometheusExporter::Client.new(
host: ENV.fetch('PROMETHEUS_EXPORTER_HOST', '0.0.0.0'),
host: ENV.fetch('PROMETHEUS_EXPORTER_HOST', 'ANY'),
port: ENV.fetch('PROMETHEUS_EXPORTER_PORT', 9394)
)
end

View file

@ -38,12 +38,17 @@ Rails.application.routes.draw do
end
resources :notifications, only: %i[index show destroy]
post 'notifications/mark_as_read', to: 'notifications#mark_as_read', as: :mark_notifications_as_read
post 'notifications/destroy_all', to: 'notifications#destroy_all', as: :delete_all_notifications
resources :stats, only: :index do
collection do
post :update
put :update_all
end
end
get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ }
put 'stats/:year/:month/update',
to: 'stats#update',
as: :update_year_month_stats,
constraints: { year: /\d{4}/, month: /\d{1,2}|all/ }
root to: 'home#index'
devise_for :users, skip: [:registrations]
@ -52,8 +57,6 @@ Rails.application.routes.draw do
put 'users' => 'devise/registrations#update', :as => 'user_registration'
end
# And then modify the app/views/devise/shared/_links.erb
get 'map', to: 'map#index'
namespace :api do
@ -78,6 +81,11 @@ Rails.application.routes.draw do
namespace :countries do
resources :borders, only: :index
resources :visited_cities, only: :index
end
namespace :points do
get 'tracked_months', to: 'tracked_months#index'
end
resources :photos, only: %i[index] do

View file

@ -2,9 +2,7 @@
class SetReverseGeocodedAtForPoints < ActiveRecord::Migration[7.2]
def up
# rubocop:disable Rails/SkipsModelValidations
Point.where.not(geodata: {}).update_all(reverse_geocoded_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
DataMigrations::SetReverseGeocodedAtForPointsJob.perform_later
end
def down

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
class AddIndexToPointsTimestamp < ActiveRecord::Migration[7.2]
disable_ddl_transaction!
def change
add_index :points, %i[user_id timestamp], algorithm: :concurrently
end
end

View file

@ -18,10 +18,12 @@ services:
timeout: 10s
dawarich_db:
image: postgres:14.2-alpine
shm_size: 1G
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
- dawarich_shared:/var/shared
# - ./postgresql.conf:/etc/postgresql/postgresql.conf # Optional, uncomment if you want to use a custom config
networks:
- dawarich
environment:
@ -34,11 +36,12 @@ services:
retries: 5
start_period: 30s
timeout: 10s
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
dawarich_app:
image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- dawarich_gem_cache_app:/usr/local/bundle/gems_app
- dawarich_gem_cache_app:/usr/local/bundle/gems
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
@ -97,7 +100,7 @@ services:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
volumes:
- dawarich_gem_cache_sidekiq:/usr/local/bundle/gems_sidekiq
- dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:

View file

@ -1,5 +1,7 @@
# How to install Dawarich on Kubernetes
> An **unofficial Helm chart** is available [here](https://github.com/Cogitri/charts/tree/master/charts/dawarich). For a manual installation using YAML manifests, see below.
## Prerequisites
- Kubernetes cluster and basic kubectl knowledge.
@ -7,6 +9,7 @@
- Working Postgres and Redis instances. In this example Postgres lives in 'db' namespace and Redis in 'redis' namespace.
- Ngingx ingress controller with Letsencrypt integeation.
- This example uses 'example.com' as a domain name, you want to change it to your own.
- This will work on IPv4 and IPv6 Single Stack clusters, as well as Dual Stack deployments.
## Installation
@ -140,8 +143,8 @@ spec:
image: freikin/dawarich:0.16.4
imagePullPolicy: Always
volumeMounts:
- mountPath: /usr/local/bundle/gems_app
name: gem-cache
- mountPath: /usr/local/bundle/gems
name: gem-app
- mountPath: /var/app/public
name: public
- mountPath: /var/app/tmp/imports/watched
@ -149,7 +152,7 @@ spec:
command:
- "dev-entrypoint.sh"
args:
- "bin/rails server -p 3000 -b 0.0.0.0"
- "bin/rails server -p 3000 -b ::"
resources:
requests:
memory: "1Gi"
@ -196,7 +199,7 @@ spec:
image: freikin/dawarich:0.16.4
imagePullPolicy: Always
volumeMounts:
- mountPath: /usr/local/bundle/gems_sidekiq
- mountPath: /usr/local/bundle/gems
name: gem-sidekiq
- mountPath: /var/app/public
name: public

36
postgresql.conf.example Normal file
View file

@ -0,0 +1,36 @@
listen_addresses = '*'
max_connections = 50
shared_buffers = 512MB
work_mem = 128MB
maintenance_work_mem = 128MB
dynamic_shared_memory_type = posix
checkpoint_timeout = 10min # range 30s-1d
max_wal_size = 2GB
min_wal_size = 80MB
max_parallel_workers_per_gather = 4
log_min_duration_statement = 500 # -1 is disabled, 0 logs all statements
# -1 disables, 0 logs all temp files
log_timezone = 'UTC'
autovacuum_vacuum_scale_factor = 0.05 # fraction of table size before vacuum
autovacuum_analyze_scale_factor = 0.05 # fraction of table size before analyze
datestyle = 'iso, dmy'
timezone = 'UTC'
lc_messages = 'en_US.utf8' # locale for system error message
# strings
lc_monetary = 'en_US.utf8' # locale for monetary formatting
lc_numeric = 'en_US.utf8' # locale for number formatting
lc_time = 'en_US.utf8' # locale for time formatting
default_text_search_config = 'pg_catalog.english'

114
public/400.html Normal file
View file

@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<title>The server cannot process the request due to a client error (400 Bad Request)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/400.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m123.606 85.4445c3.212 1.0523 5.538 4.2089 5.538 8.0301 0 6.1472-4.209 9.5254-11.298 9.5254h-15.617v-34.0033h14.565c7.089 0 11.353 3.1566 11.353 9.2484 0 3.6551-2.049 6.3134-4.541 7.1994zm-12.904-2.9905h5.095c2.603 0 3.988-.9968 3.988-3.1013 0-2.1044-1.385-3.0459-3.988-3.0459h-5.095zm0 6.6456v6.5902h5.981c2.492 0 3.877-1.3291 3.877-3.2674 0-2.049-1.385-3.3228-3.877-3.3228zm43.786 13.9004h-8.362v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.083-2.769-9.083-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.483 1.3845v-1.606c0-1.606-.942-2.9905-3.046-2.9905-1.606 0-2.548.7199-2.935 1.8275h-8.197c.72-4.8181 4.985-8.6393 11.409-8.6393 7.088 0 11.131 3.7659 11.131 10.2453zm-8.362-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm27.996 6.9779v-1.994c-1.163 1.329-3.599 2.548-6.147 2.548-7.199 0-11.131-5.8151-11.131-13.0145s3.932-13.0143 11.131-13.0143c2.548 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.664-1.3291-2.159-2.326-3.821-2.326-2.99 0-4.763 2.4368-4.763 5.6488s1.773 5.5934 4.763 5.5934c1.717 0 3.157-.9415 3.821-2.326zm35.471-2.049h-3.101v11.2421h-8.806v-34.0033h15.285c7.31 0 12.35 4.1535 12.35 11.5744 0 5.1503-2.603 8.6947-6.757 10.2453l7.975 12.1836h-9.858zm-3.101-15.2849v8.1962h5.538c3.156 0 4.596-1.606 4.596-4.0981s-1.44-4.0981-4.596-4.0981zm36.957 17.8323h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm30.98 27.5234v-10.799c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.9259-11.132-13.0145 0-7.144 3.932-13.0143 11.132-13.0143 2.547 0 4.984 1.2184 6.147 2.5475v-1.9937h8.695v33.726zm0-17.9981v-6.5902c-.665-1.3291-2.105-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.661 0 3.156-.9415 3.821-2.326zm36.789-15.7279v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.996 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm19.084 16.2263h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.963 5.095 11.963 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm13.428 11.0206h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.762-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm27.538-.8861v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.993-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.871 0-9.193-2.769-9.193-9.0819z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>The server cannot process the request due to a client error.</strong> Please check the request and try again. If youre the application owner check the logs for more information.</p>
</article>
</main>
</body>
</html>

View file

@ -1,67 +1,114 @@
<!DOCTYPE html>
<html>
<head>
<title>The page you were looking for doesn't exist (404)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
.rails-default-error-page {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
<!doctype html>
.rails-default-error-page div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
<html lang="en">
.rails-default-error-page div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
<head>
.rails-default-error-page h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
<title>The page you were looking for doesnt exist (404 Not found)</title>
.rails-default-error-page div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
</style>
</head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/404.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm165.328-35.41581-45.689 100.02991h26.224v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.184v-31.901l50.285-103.27391z" fill="#f0eff0"/><path d="m157.758 68.9967v34.0033h-7.199l-14.233-19.8814v19.8814h-8.584v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.528 0c0-3.4336-1.496-5.8703-4.209-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.209-2.3813 4.209-5.8149zm13.184 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm37.027 8.5839h-8.806v-34.0033h23.924v7.6978h-15.118v6.7564h13.9v7.5316h-13.9zm41.876-12.4605c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm35.337-12.4605v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.997 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm4.076 24.921v-24.921h8.694v2.1598c1.385-1.5506 3.822-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.639v-14.2327c0-2.049-1.053-3.5443-3.268-3.5443-1.717 0-3.156.9969-3.6 2.7136v15.0634zm44.113 0v-1.994c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.8151-11.132-13.0145s3.932-13.0143 11.132-13.0143c2.547 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.665-1.3291-2.16-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.717 0 3.156-.9415 3.821-2.326z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>The page you were looking for doesnt exist.</strong> You may have mistyped the address or the page may have moved. If youre the application owner check the logs for more information.</p>
</article>
</main>
</body>
<body class="rails-default-error-page">
<!-- This file lives in public/404.html -->
<div class="dialog">
<div>
<h1>The page you were looking for doesn't exist.</h1>
<p>You may have mistyped the address or the page may have moved.</p>
</div>
<p>If you are the application owner check the logs for more information.</p>
</div>
</body>
</html>

View file

@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<title>Your browser is not supported (406 Not Acceptable)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/406-unsupported-browser.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm202.906 9.7326h-41.093c-2.433-7.2994-7.84-12.4361-17.302-12.4361-16.221 0-25.413 17.5728-25.954 34.8752v1.3517c5.137-7.0291 16.221-12.4361 30.82-12.4361 33.524 0 54.881 24.0612 54.881 53.7998 0 33.253-23.791 58.396-61.64 58.396-21.628 0-39.741-10.003-50.825-27.576-9.733-14.599-13.788-32.442-13.788-54.3406 0-51.9072 24.331-89.485807 66.236-89.485807 32.712 0 53.258 18.654107 58.665 47.851907zm-82.727 66.2355c0 13.247 9.463 22.439 22.71 22.439 12.977 0 22.439-9.192 22.439-22.439 0-13.517-9.462-22.7091-22.439-22.7091-13.247 0-22.71 9.1921-22.71 22.7091z" fill="#f0eff0"/><path d="m100.761 68.9967v34.0033h-7.1991l-14.2326-19.8814v19.8814h-8.5839v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm13.185 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.02-25.4194h9.083l12.958 34.0033h-9.027l-2.436-6.5902h-12.35l-2.381 6.5902h-8.806zm4.431 10.5222-3.489 9.5807h6.978zm17.44 11.0206c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm25.676 0c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm42.013 3.7658h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm23.4 16.7244v10.799h-8.694v-33.726h8.694v1.9937c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8703 11.131 13.0143 0 7.0886-3.932 13.0145-11.131 13.0145-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.16 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.717 0-3.157.9969-3.822 2.326zm21.892 7.1994v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.458 8.5839h-8.363v-1.274c-.83.831-3.322 1.717-5.981 1.717-4.928 0-9.082-2.769-9.082-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.045-2.9905-1.606 0-2.548.7199-2.936 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.553-1.0522-2.049-1.7167-3.655-1.7167-1.716 0-3.433.7199-3.433 2.3813 0 1.7168 1.717 2.4367 3.433 2.4367 1.606 0 3.102-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.694v-35.997h8.694v13.0697c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8149 11.131 13.0143s-3.932 13.0145-11.131 13.0145c-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.662 0-3.157.9969-3.822 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>Your browser is not supported.</strong><br> Please upgrade your browser to continue.</p>
</article>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

3
public/icon.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="red"/>
</svg>

After

Width:  |  Height:  |  Size: 122 B

View file

@ -13,44 +13,6 @@ RSpec.describe Stat, type: :model do
let(:year) { 2021 }
let(:user) { create(:user) }
describe '.year_cities_and_countries' do
subject { described_class.year_cities_and_countries(year, user) }
let(:timestamp) { DateTime.new(year, 1, 1, 0, 0, 0) }
before do
stub_const('MIN_MINUTES_SPENT_IN_CITY', 60)
end
context 'when there are points' do
let!(:points) do
[
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp:),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 10.minutes),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 20.minutes),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 30.minutes),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 40.minutes),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 50.minutes),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 60.minutes),
create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 70.minutes),
create(:point, user:, city: 'Brugges', country: 'Belgium', timestamp: timestamp + 80.minutes),
create(:point, user:, city: 'Brugges', country: 'Belgium', timestamp: timestamp + 90.minutes)
]
end
it 'returns countries and cities' do
# User spent only 20 minutes in Brugges, so it should not be included
expect(subject).to eq(countries: 2, cities: 1)
end
end
context 'when there are no points' do
it 'returns countries and cities' do
expect(subject).to eq(countries: 0, cities: 0)
end
end
end
describe '#distance_by_day' do
subject { stat.distance_by_day }

View file

@ -117,10 +117,8 @@ RSpec.describe User, type: :model do
describe '#years_tracked' do
let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) }
subject { user.years_tracked }
it 'returns years tracked' do
expect(subject).to eq([2024])
expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }])
end
end
end

View file

@ -7,7 +7,7 @@ RSpec.describe '/api/v1/areas', type: :request do
describe 'GET /index' do
it 'renders a successful response' do
get api_v1_areas_url(api_key: user.api_key)
get api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" }
expect(response).to be_successful
end
end
@ -20,12 +20,14 @@ RSpec.describe '/api/v1/areas', type: :request do
it 'creates a new Area' do
expect do
post api_v1_areas_url(api_key: user.api_key), params: { area: valid_attributes }
post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: valid_attributes }
end.to change(Area, :count).by(1)
end
it 'redirects to the created api_v1_area' do
post api_v1_areas_url(api_key: user.api_key), params: { area: valid_attributes }
post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: valid_attributes }
expect(response).to have_http_status(:created)
end
@ -38,12 +40,15 @@ RSpec.describe '/api/v1/areas', type: :request do
it 'does not create a new Area' do
expect do
post api_v1_areas_url(api_key: user.api_key), params: { area: invalid_attributes }
post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
end.to change(Area, :count).by(0)
end
it 'renders a response with 422 status' do
post api_v1_areas_url(api_key: user.api_key), params: { area: invalid_attributes }
post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
@ -56,14 +61,16 @@ RSpec.describe '/api/v1/areas', type: :request do
let(:new_attributes) { attributes_for(:area).merge(name: 'New Name') }
it 'updates the requested api_v1_area' do
patch api_v1_area_url(area, api_key: user.api_key), params: { area: new_attributes }
patch api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: new_attributes }
area.reload
expect(area.reload.name).to eq('New Name')
end
it 'redirects to the api_v1_area' do
patch api_v1_area_url(area, api_key: user.api_key), params: { area: new_attributes }
patch api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: new_attributes }
area.reload
expect(response).to have_http_status(:ok)
@ -75,7 +82,8 @@ RSpec.describe '/api/v1/areas', type: :request do
let(:invalid_attributes) { attributes_for(:area, name: nil) }
it 'renders a response with 422 status' do
patch api_v1_area_url(area, api_key: user.api_key), params: { area: invalid_attributes }
patch api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
@ -87,12 +95,12 @@ RSpec.describe '/api/v1/areas', type: :request do
it 'destroys the requested api_v1_area' do
expect do
delete api_v1_area_url(area, api_key: user.api_key)
delete api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" }
end.to change(Area, :count).by(-1)
end
it 'redirects to the api_v1_areas list' do
delete api_v1_area_url(area, api_key: user.api_key)
delete api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" }
expect(response).to have_http_status(:ok)
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do
describe 'GET /index' do
let(:user) { create(:user) }
let(:start_at) { '2023-01-01' }
let(:end_at) { '2023-12-31' }
it 'returns visited cities' do
get "/api/v1/countries/visited_cities?api_key=#{user.api_key}&start_at=#{start_at}&end_at=#{end_at}"
expect(response).to have_http_status(:ok)
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Points::TrackedMonths', type: :request do
describe 'GET /index' do
let(:user) { create(:user) }
it 'returns tracked months' do
get "/api/v1/points/tracked_months?api_key=#{user.api_key}"
expect(response).to have_http_status(:ok)
end
end
end

View file

@ -49,7 +49,7 @@ RSpec.describe '/exports', type: :request do
expect(response).to redirect_to(exports_url)
end
it 'enqeuues a job to process the export' do
it 'enqueues a job to process the export' do
ActiveJob::Base.queue_adapter = :test
expect { post exports_url, params: }.to have_enqueued_job(ExportJob)

View file

@ -27,12 +27,10 @@ RSpec.describe '/stats', type: :request do
end
context 'when user is signed in' do
before do
sign_in user
end
let(:user) { create(:user) }
before { sign_in user }
describe 'GET /index' do
it 'renders a successful response' do
get stats_url
@ -54,15 +52,37 @@ RSpec.describe '/stats', type: :request do
describe 'POST /update' do
let(:stat) { create(:stat, user:, year: 2024) }
it 'enqueues Stats::CalculatingJob for each tracked year and month' do
allow(user).to receive(:years_tracked).and_return([2024])
context 'when updating a specific month' do
it 'enqueues Stats::CalculatingJob for the given year and month' do
put update_year_month_stats_url(year: '2024', month: '1')
post stats_url
(1..12).each do |month|
expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, month)
expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, '2024', '1')
end
end
context 'when updating the whole year' do
it 'enqueues Stats::CalculatingJob for each month of the year' do
put update_year_month_stats_url(year: '2024', month: 'all')
(1..12).each do |month|
expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, '2024', month)
end
end
end
end
describe 'PUT /update_all' do
let(:stat) { create(:stat, user:, year: 2024) }
it 'enqueues Stats::CalculatingJob for each tracked year and month' do
allow(user).to receive(:years_tracked).and_return([{ year: 2024, months: %w[Jan Feb] }])
put update_all_stats_url
expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, 1)
expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, 2)
expect(Stats::CalculatingJob).to_not have_been_enqueued.with(user.id, 2024, 3)
end
end
end
end

View file

@ -36,13 +36,18 @@ RSpec.describe CountriesAndCities do
it 'returns countries and cities' do
expect(countries_and_cities).to eq(
[
{
cities: [{ city: 'Berlin', points: 8, timestamp: 1609463400, stayed_for: 70 }],
country: 'Germany'
},
{
cities: [], country: 'Belgium'
}
CountriesAndCities::CountryData.new(
country: 'Germany',
cities: [
CountriesAndCities::CityData.new(
city: 'Berlin', points: 8, timestamp: 1_609_463_400, stayed_for: 70
)
]
),
CountriesAndCities::CountryData.new(
country: 'Belgium',
cities: []
)
]
)
end
@ -62,12 +67,14 @@ RSpec.describe CountriesAndCities do
it 'returns countries and cities' do
expect(countries_and_cities).to eq(
[
{
cities: [], country: 'Germany'
},
{
cities: [], country: 'Belgium'
}
CountriesAndCities::CountryData.new(
country: 'Germany',
cities: []
),
CountriesAndCities::CountryData.new(
country: 'Belgium',
cities: []
)
]
)
end

View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do
path '/api/v1/countries/visited_cities' do
get 'Get visited cities by date range' do
tags 'Countries'
description 'Returns a list of visited cities and countries based on tracked points within the specified date range'
produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true
parameter name: :start_at,
in: :query,
type: :string,
format: 'date-time',
required: true,
description: 'Start date in YYYY-MM-DD format',
example: '2023-01-01'
parameter name: :end_at,
in: :query,
type: :string,
format: 'date-time',
required: true,
description: 'End date in YYYY-MM-DD format',
example: '2023-12-31'
response '200', 'cities found' do
schema type: :object,
properties: {
data: {
type: :array,
description: 'Array of countries and their visited cities',
items: {
type: :object,
properties: {
country: {
type: :string,
example: 'Germany'
},
cities: {
type: :array,
items: {
type: :object,
properties: {
city: {
type: :string,
example: 'Berlin'
},
points: {
type: :integer,
example: 4394,
description: 'Number of points in the city'
},
timestamp: {
type: :integer,
example: 1_724_868_369,
description: 'Timestamp of the last point in the city in seconds since Unix epoch'
},
stayed_for: {
type: :integer,
example: 24_490,
description: 'Number of minutes the user stayed in the city'
}
}
}
}
}
}
}
}
let(:start_at) { '2023-01-01' }
let(:end_at) { '2023-12-31' }
let(:api_key) { create(:user).api_key }
run_test!
end
response '400', 'bad request - missing parameters' do
schema type: :object,
properties: {
error: {
type: :string,
example: 'Missing required parameters: start_at, end_at'
}
}
let(:start_at) { nil }
let(:end_at) { nil }
let(:api_key) { create(:user).api_key }
run_test!
end
end
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'swagger_helper'
describe 'Points Tracked Months API', type: :request do
path '/api/v1/points/tracked_months' do
get 'Returns list of tracked years and months' do
tags 'Points'
produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'years and months found' do
schema type: :array,
items: {
type: :object,
properties: {
year: { type: :integer, description: 'Year in YYYY format' },
months: {
type: :array,
items: { type: :string, description: 'Three-letter month abbreviation' }
}
},
required: %w[year months]
},
example: [{
year: 2024,
months: %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec]
}]
let(:api_key) { create(:user).api_key }
run_test!
end
response '401', 'unauthorized' do
let(:api_key) { 'invalid' }
run_test!
end
end
end
end

View file

@ -106,6 +106,84 @@ paths:
responses:
'200':
description: area deleted
"/api/v1/countries/visited_cities":
get:
summary: Get visited cities by date range
tags:
- Countries
description: Returns a list of visited cities and countries based on tracked
points within the specified date range
parameters:
- name: api_key
in: query
required: true
schema:
type: string
- name: start_at
in: query
format: date-time
required: true
description: Start date in YYYY-MM-DD format
example: '2023-01-01'
schema:
type: string
- name: end_at
in: query
format: date-time
required: true
description: End date in YYYY-MM-DD format
example: '2023-12-31'
schema:
type: string
responses:
'200':
description: cities found
content:
application/json:
schema:
type: object
properties:
data:
type: array
description: Array of countries and their visited cities
items:
type: object
properties:
country:
type: string
example: Germany
cities:
type: array
items:
type: object
properties:
city:
type: string
example: Berlin
points:
type: integer
example: 4394
description: Number of points in the city
timestamp:
type: integer
example: 1724868369
description: Timestamp of the last point in the city
in seconds since Unix epoch
stayed_for:
type: integer
example: 24490
description: Number of minutes the user stayed in
the city
'400':
description: bad request - missing parameters
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: 'Missing required parameters: start_at, end_at'
"/api/v1/health":
get:
summary: Retrieves application status
@ -460,6 +538,56 @@ paths:
- photoprism
'404':
description: photo not found
"/api/v1/points/tracked_months":
get:
summary: Returns list of tracked years and months
tags:
- Points
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'200':
description: years and months found
content:
application/json:
schema:
type: array
items:
type: object
properties:
year:
type: integer
description: Year in YYYY format
months:
type: array
items:
type: string
description: Three-letter month abbreviation
required:
- year
- months
example:
- year: 2024
months:
- Jan
- Feb
- Mar
- Apr
- May
- Jun
- Jul
- Aug
- Sep
- Oct
- Nov
- Dec
'401':
description: unauthorized
"/api/v1/points":
get:
summary: Retrieves all points