Merge remote-tracking branch 'origin/master' into feature/production_env

This commit is contained in:
Eugene Burmakin 2025-01-07 16:02:12 +01:00
commit e2f1b2a26c
225 changed files with 8132 additions and 2581 deletions

View file

@ -1 +1 @@
0.16.9
0.21.6

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,76 @@
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_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
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:

View file

@ -4,5 +4,4 @@ DATABASE_PASSWORD=password
DATABASE_NAME=dawarich_development
DATABASE_PORT=5432
REDIS_URL=redis://localhost:6379/1
PHOTON_API_HOST='photon.komoot.io'
DISTANCE_UNIT='km'

View file

@ -7,11 +7,14 @@ assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**OS & Hardware**
Provide your software and hardware specs
**Version**
Include version of Dawarich you're experiencing problem on.
Provide the version of Dawarich you're experiencing the problem on.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:

View file

@ -6,7 +6,7 @@ on:
branch:
description: "The branch to build the Docker image from"
required: false
default: "main"
default: "master"
release:
types: [created]
@ -23,7 +23,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

12
.gitignore vendored
View file

@ -51,3 +51,15 @@
!/app/assets/builds/.keep
.DS_Store
.env
.byebug_history
.devcontainer/.onCreateCommandMarker
.devcontainer/.postCreateCommandMarker
.devcontainer/.updateContentCommandMarker
.vscode-server/
.ash_history
.cache/
.dotnet/

View file

@ -5,6 +5,430 @@ 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.6 - 2025-01-07
### Changed
- Disabled visit suggesting job after import.
- Improved performance of the `User#years_tracked` method.
### Fixed
- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605
- Points are now being rendered with higher z-index than polylines. #577
- Run cache cleaning and preheating jobs only on server start. #594
# 0.21.5 - 2025-01-07
You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/).
### Added
- Geoapify API support for reverse geocoding. Provide `GEOAPIFY_API_KEY` env var to use it.
### Removed
- Photon ENV vars from the `.env.development` and docker-compose.yml files.
- `APPLICATION_HOST` env var.
- `REVERSE_GEOCODING_ENABLED` env var.
# 0.21.4 - 2025-01-05
### Fixed
- Fixed a bug where Photon API for patreon supporters was not being used for reverse geocoding.
# 0.21.3 - 2025-01-04
### Added
- A notification about Photon API being under heavy load.
### Removed
- The notification about telemetry being enabled.
### Reverted
- ~~Imported points will now be reverse geocoded only after import is finished.~~
# 0.21.2 - 2024-12-25
### Added
- Logging for Immich responses.
- Watcher now supports all data formats that can be imported via web interface.
### Changed
- Imported points will now be reverse geocoded only after import is finished.
### Fixed
- Markers on the map are now being rendered with higher z-index than polylines. #577
# 0.21.1 - 2024-12-24
### Added
- Cache cleaning and preheating upon application start.
- `PHOTON_API_KEY` env var to set Photon API key. It's an optional env var, but it's required if you want to use Photon API as a Patreon supporter.
- 'X-Dawarich-Response' header to the `GET /api/v1/health` endpoint. It's set to 'Hey, I\'m alive!' to make it easier to check if the API is working.
### Changed
- Custom config for PostgreSQL is now optional in `docker-compose.yml`.
# 0.21.0 - 2024-12-20
⚠️ This release introduces a breaking change. ⚠️
The `dawarich_db` service now uses a custom `postgresql.conf` file.
As @tabacha pointed out in #549, the default `shm_size` for the `dawarich_db` service is too small and it may lead to database performance issues. This release introduces a `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations. Also, it introduces a custom `postgresql.conf` file to the `dawarich_db` service.
To mount a custom `postgresql.conf` file, you need to create a `postgresql.conf` file in the `dawarich_db` service directory and add the following line to it:
```diff
dawarich_db:
image: postgres:14.2-alpine
shm_size: 1G
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
- dawarich_shared:/var/shared
+ - ./postgresql.conf:/etc/postgresql/postgres.conf # Provide path to custom config
...
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
+ command: postgres -c config_file=/etc/postgresql/postgres.conf # Use custom config
```
To ensure your database is using custom config, you can connect to the container (`docker exec -it dawarich_db psql -U postgres`) and run `SHOW config_file;` command. It should return the following path: `/etc/postgresql/postgresql.conf`.
An example of a custom `postgresql.conf` file is provided in the `postgresql.conf.example` file.
### Added
- A button on a year stats card to update stats for the whole year. #466
- A button on a month stats card to update stats for a specific month. #466
- A confirmation alert on the Notifications page before deleting all notifications.
- A `shm_size` parameter to the `dawarich_db` service to increase the size of the shared memory for PostgreSQL. This should help database with peforming vacuum and other operations.
```diff
...
dawarich_db:
image: postgres:14.2-alpine
+ shm_size: 1G
...
```
- In addition to `api_key` parameter, `Authorization` header is now being used to authenticate API requests. #543
Example:
```
Authorization: Bearer YOUR_API_KEY
```
### Changed
- The map borders were expanded to make it easier to scroll around the map for New Zealanders.
- The `dawarich_db` service now uses a custom `postgresql.conf` file.
- The popup over polylines now shows dates in the user's format, based on their browser settings.
# 0.20.2 - 2024-12-17
### Added
- A point id is now being shown in the point popup.
### Fixed
- North Macedonia is now being shown on the scratch map. #537
### Changed
- The app process is now bound to :: instead of 0.0.0.0 to provide compatibility with IPV6.
- The app was updated to use Rails 8.0.1.
# 0.20.1 - 2024-12-16
### Fixed
- Setting `reverse_geocoded_at` for points that don't have geodata is now being performed in background job, in batches of 10,000 points to prevent memory exhaustion and long-running data migration.
# 0.20.0 - 2024-12-16
### Added
- `GET /api/v1/points/tracked_months` endpoint added to get list of tracked years and months.
- `GET /api/v1/countries/visited_cities` endpoint added to get list of visited cities.
- A link to the docs leading to a help chart for k8s. #550
- A button to delete all notifications. #548
- A support for `RAILS_LOG_LEVEL` env var to change log level. More on that here: https://guides.rubyonrails.org/debugging_rails_applications.html#log-levels. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`, corresponding to the log level numbers from 0 up to 5, respectively. The default log level is `:debug`. #540
- A devcontainer to improve developers experience. #546
### Fixed
- A point popup is no longer closes when hovering over a polyline. #536
- When polylines layer is disabled and user deletes a point from its popup, polylines layer is no longer being enabled right away. #552
- Paths to gems within the sidekiq and app containers. #499
### Changed
- Months and years navigation is moved to a map panel on the right side of the map.
- List of visited cities is now being shown in a map panel on the right side of the map.
# 0.19.7 - 2024-12-11
### Fixed
- Fixed a bug where upon deleting a point on the map, the confirmation dialog was shown multiple times and the point was not being deleted from the map until the page was reloaded. #435
### Changed
- With the "Points" layer enabled on the map, points with negative speed are now being shown in orange color. Since Overland reports negative speed for points that might be faulty, this should help you to identify them.
- On the Points page, speed of the points with negative speed is now being shown in red color.
# 0.19.6 - 2024-12-11
⚠️ This release introduces a breaking change. ⚠️
The `dawarich_shared` volume now being mounted to `/data` instead of `/var/shared` within the container. It fixes Redis data being lost on container restart.
To change this, you need to update the `docker-compose.yml` file:
```diff
dawarich_redis:
image: redis:7.0-alpine
container_name: dawarich_redis
command: redis-server
volumes:
+ - dawarich_shared:/data
restart: always
healthcheck:
```
Telemetry is now disabled by default. To enable it, you need to set `ENABLE_TELEMETRY` env var to `true`. For those who have telemetry enabled using `DISABLE_TELEMETRY` env var set to `false`, telemetry is now disabled by default.
### Fixed
- Flash messages are now being removed after 5 seconds.
- Fixed broken migration that was preventing the app from starting.
- Visits page is now loading a lot faster than before.
- Redis data should now be preserved on container restart.
- Fixed a bug where export files could have double extension, e.g. `file.gpx.gpx`.
### Changed
- Places page is now accessible from the Visits & Places tab on the navbar.
- Exporting process is now being logged.
- `ENABLE_TELEMETRY` env var is now used instead of `DISABLE_TELEMETRY` to enable/disable telemetry.
# 0.19.5 - 2024-12-10
### Fixed
- Fixed a bug where the map and visits pages were throwing an error due to incorrect approach to distance calculation.
# 0.19.4 - 2024-12-10
⚠️ This release introduces a breaking change. ⚠️
The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of the response:
```diff
{
id: 1,
latitude: 10,
longitude: 10,
localDateTime: "2024-01-01T00:00:00Z",
originalFileName: "photo.jpg",
city: "Berlin",
state: "Berlin",
country: "Germany",
type: "image",
+ orientation: "portrait",
source: "photoprism"
}
```
### Fixed
- Fixed a bug where the Photoprism photos were not being shown on the trip page.
- Fixed a bug where the Immich photos were not being shown on the trip page.
- Fixed a bug where the route popup was showing distance in kilometers instead of miles. #490
### Added
- A link to the Photoprism photos on the trip page if there are any.
- A `orientation` field in the Api::PhotoSerializer, hence the `GET /api/v1/photos` endpoint now includes the orientation of the photo. Valid values are `portrait` and `landscape`.
- Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI.
- `DISABLE_TELEMETRY` env var to disable telemetry. More on telemetry: https://dawarich.app/docs/tutorials/telemetry
- `reverse_geocoded_at` column added to the `points` table.
### Changed
- On the Stats page, the "Reverse geocoding" section is now showing the number of points that were reverse geocoded based on `reverse_geocoded_at` column, value of which is based on the time when the point was reverse geocoded. If no geodata for the point is available, `reverse_geocoded_at` will be set anyway. Number of points that were reverse geocoded but no geodata is available for them is shown below the "Reverse geocoded" number.
# 0.19.3 - 2024-12-06
### Changed
- Refactored stats calculation to calculate only necessary stats, instead of calculating all stats
- Stats are now being calculated every 1 hour instead of 6 hours
- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points.
- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion.
### Added
- In-app notification about telemetry being enabled.
# 0.19.2 - 2024-12-04
## The Telemetry release
Dawarich now can collect usage metrics and send them to InfluxDB. Before this release, the only metrics that could be somehow tracked by developers (only @Freika, as of now) were the number of stars on GitHub and the overall number of docker images being pulled, across all versions of Dawarich, non-splittable by version. New in-app telemetry will allow us to track more granular metrics, allowing me to make decisions based on facts, not just guesses.
I'm aware about the privacy concerns, so I want to be very transparent about what data is being sent and how it's used.
Data being sent:
- Number of DAU (Daily Active Users)
- App version
- Instance ID (unique identifier of the Dawarich instance built by hashing the api key of the first user in the database)
The data is being sent to a InfluxDB instance hosted by me and won't be shared with anyone.
Basically this set of metrics allows me to see how many people are using Dawarich and what versions they are using. No other data is being sent, nor it gives me any knowledge about individual users or their data or activity.
The telemetry is enabled by default, but it **can be disabled** by setting `DISABLE_TELEMETRY` env var to `true`. The dataset might change in the future, but any changes will be documented here in the changelog and in every release as well as on the [telemetry page](https://dawarich.app/docs/tutorials/telemetry) of the website docs.
### Added
- Telemetry feature. It's now collecting usage metrics and sending them to InfluxDB.
# 0.19.1 - 2024-12-04
### Fixed
- Sidekiq is now being correctly exported to Prometheus with `PROMETHEUS_EXPORTER_ENABLED=true` env var in `dawarich_sidekiq` service.
# 0.19.0 - 2024-12-04
## The Photoprism integration release
⚠️ This release introduces a breaking change. ⚠️
The `GET /api/v1/photos` endpoint now returns following structure of the response:
```json
[
{
"id": "1",
"latitude": 11.22,
"longitude": 12.33,
"localDateTime": "2024-01-01T00:00:00Z",
"originalFileName": "photo.jpg",
"city": "Berlin",
"state": "Berlin",
"country": "Germany",
"type": "image", // "image" or "video"
"source": "photoprism" // "photoprism" or "immich"
}
]
```
### Added
- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process.
### Fixed
- z-index on maps so they won't overlay notifications dropdown
- Redis connectivity where it's not required
# 0.18.2 - 2024-11-29
### Added
- Demo account. You can now login with `demo@dawarich.app` / `password` to see how Dawarich works. This replaces previous default credentials.
### Changed
- The login page now shows demo account credentials if `DEMO_ENV` env var is set to `true`.
# 0.18.1 - 2024-11-29
### Fixed
- Fixed a bug where the trips interface was breaking when Immich integration is not configured.
### Added
- Flash messages are now being shown on the map when Immich integration is not configured.
# 0.18.0 - 2024-11-28
## The Trips release
You can now create, edit and delete trips. To create a trip, click on the "New Trip" button on the Trips page. Provide a name, date and time for start and end of the trip. You can add your own notes to the trip as well.
If you have points tracked during provided timeframe, they will be automatically added to the trip and will be shown on the trip map.
Also, if you have Immich integrated, you will see photos from the trip on the trip page, along with a link to look at them on Immich.
### Added
- The Trips feature. Read above for more details.
### Changed
- Maps are now not so rough on the edges.
# 0.17.2 - 2024-11-27
### Fixed
- Retrieving photos from Immich now using `takenAfter` and `takenBefore` instead of `createdAfter` and `createdBefore`. With `createdAfter` and `createdBefore` Immich was returning no items some years.
# 0.17.1 - 2024-11-27
### Fixed
- Retrieving photos from Immich now correctly handles cases when Immich returns no items. It also logs the response from Immich for debugging purposes.
# 0.17.0 - 2024-11-26
## The Immich Photos release
With this release, Dawarich can now show photos from your Immich instance on the map.
To enable this feature, you need to provide your Immich instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
An important note to add here is that photos are heavy and hence generate a lot of traffic. The response from Immich for specific dates is being cached in Redis for 1 day, and that may lead to Redis taking a lot more space than previously. But since the cache is being expired after 24 hours, you'll get your space back pretty soon.
The other thing worth mentioning is how Dawarich gets data from Immich. It goes like this:
1. When you click on the "Photos" layer, Dawarich will make a request to `GET /api/v1/photos` endpoint to get photos for the selected timeframe.
2. This endpoint will make a request to `POST /search/metadata` endpoint of your Immich instance to get photos for the selected timeframe.
3. The response from Immich is being cached in Redis for 1 day.
4. Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. The number of requests to this endpoint will depend on how many photos you have in the selected timeframe.
5. For each photo, Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. This thumbnail request is also cached in Redis for 1 day.
### Added
- If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled.
- `GET /api/v1/photos` endpoint added to get photos from Immich.
- `GET /api/v1/photos/:id/thumbnail.jpg` endpoint added to get photo thumbnail from Immich.
# 0.16.9 - 2024-11-24
### Changed

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)
@ -88,7 +88,7 @@ GEM
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
bigdecimal (3.1.8)
bigdecimal (3.1.9)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
@ -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,15 +156,15 @@ 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)
json (2.9.1)
json-schema (5.0.1)
addressable (~> 2.8)
kaminari (1.2.2)
@ -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,12 @@ GEM
marcel (1.0.4)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (5.25.1)
mini_portile2 (2.8.8)
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,32 +212,33 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.16.7-aarch64-linux)
nokogiri (1.18.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.7-arm-linux)
nokogiri (1.18.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
nokogiri (1.18.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.16.7-x86-linux)
nokogiri (1.18.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin)
nokogiri (1.18.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.18.1-x86_64-linux-gnu)
racc (~> 1.4)
oj (3.16.7)
oj (3.16.9)
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)
parser (3.3.6.0)
ast (~> 2.4.1)
racc
patience_diff (1.2.0)
optimist (~> 3.0)
pg (1.5.9)
prometheus_exporter (2.1.1)
prometheus_exporter (2.2.0)
webrick
pry (0.14.2)
coderay (~> 1.1)
@ -246,10 +248,11 @@ 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.4.3)
puma (6.5.0)
nio4r (~> 2.0)
pundit (2.4.0)
activesupport (>= 3.0.0)
@ -258,34 +261,34 @@ GEM
rack (3.1.8)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
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 +296,14 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rdoc (6.7.0)
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)
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@ -333,34 +336,34 @@ GEM
json-schema (>= 2.2, < 6.0)
railties (>= 5.2, < 8.1)
rspec-core (>= 2.14)
rswag-ui (2.15.0)
actionpack (>= 5.2, < 8.0)
railties (>= 5.2, < 8.0)
rubocop (1.67.0)
rswag-ui (2.16.0)
actionpack (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
rubocop (1.69.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.32.2, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.3)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0)
parser (>= 3.3.1.0)
rubocop-rails (2.27.0)
rubocop-rails (2.28.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
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.5)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
@ -392,15 +395,15 @@ GEM
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
tailwindcss-rails (3.0.0)
tailwindcss-rails (3.1.0)
railties (>= 7.0.0)
tailwindcss-ruby
tailwindcss-ruby (3.4.14)
tailwindcss-ruby (3.4.14-aarch64-linux)
tailwindcss-ruby (3.4.14-arm-linux)
tailwindcss-ruby (3.4.14-arm64-darwin)
tailwindcss-ruby (3.4.14-x86_64-darwin)
tailwindcss-ruby (3.4.14-x86_64-linux)
tailwindcss-ruby (3.4.17)
tailwindcss-ruby (3.4.17-aarch64-linux)
tailwindcss-ruby (3.4.17-arm-linux)
tailwindcss-ruby (3.4.17-arm64-darwin)
tailwindcss-ruby (3.4.17-x86_64-darwin)
tailwindcss-ruby (3.4.17-x86_64-linux)
thor (1.3.2)
timeout (0.4.2)
turbo-rails (2.0.11)
@ -409,15 +412,18 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
unicode-display_width (2.6.0)
useragent (0.16.10)
unicode-display_width (3.1.3)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.0)
webrick (1.9.1)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@ -456,7 +462,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.
@ -72,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send
- **[Synology](https://dawarich.app/docs/tutorials/platforms/synology)**
🆕 **Default Credentials**
- **Username**: `user@domain.com`
- **Username**: `demo@dawarich.app`
- **Password**: `password`
(Feel free to change them in the account settings.)
@ -99,6 +100,10 @@ Simply install one of the supported apps on your device and configure it to send
### 📊 Statistics
- Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month.
### 📸 Integrations
- Provide credentials for Immich or Photoprism (or both!) and Dawarich will automatically import geodata from your photos.
- You'll also be able to visualize your photos on the map!
### 📥 Import Your Data
- Import from various sources:
- Google Maps Timeline

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,45 @@
/*
* Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and
* the trix-editor content (whether displayed or under editing). Feel free to incorporate this
* inclusion directly in any other asset bundle and remove this file.
*
*= require trix
*/
/*
* We need to override trix.csss image gallery styles to accommodate the
* <action-text-attachment> element we wrap around attachments. Otherwise,
* images in galleries will be squished by the max-width: 33%; rule.
*/
.trix-content .attachment-gallery > action-text-attachment,
.trix-content .attachment-gallery > .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%;
}
.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
flex-basis: 50%;
max-width: 50%;
}
.trix-content action-text-attachment .attachment {
padding: 0 !important;
max-width: 100% !important;
}
/* Hide both attach files and attach images buttons in trix editor*/
.trix-button-group.trix-button-group--file-tools {
display:none;
}
/* Color buttons in white */
.trix-button-row button {
background-color: white !important;
}
.trix-content {
min-height: 10rem;
}

View file

@ -57,3 +57,53 @@
.leaflet-settings-panel button:hover {
background-color: #0056b3;
}
.photo-marker {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
}
.photo-marker img {
border-radius: 50%;
width: 48px;
height: 48px;
}
.leaflet-loading-control {
padding: 5px;
border-radius: 4px;
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
margin: 10px;
width: 32px;
height: 32px;
background: white;
}
.loading-spinner {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
color: gray;
}
.loading-spinner::before {
content: '🔵';
font-size: 18px;
animation: spinner 1s linear infinite;
}
.loading-spinner.done::before {
content: '✅';
animation: none;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}

View file

@ -12,3 +12,11 @@
}
*/
@import 'actiontext.css';
@layer components {
.fade-out {
opacity: 0;
transition: opacity 150ms ease-in-out;
}
}

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,52 @@
# frozen_string_literal: true
class Api::V1::PhotosController < ApiController
before_action :check_integration_configured, only: %i[index thumbnail]
before_action :check_source, only: %i[thumbnail]
def index
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
end
render json: @photos, status: :ok
end
def thumbnail
response = fetch_cached_thumbnail(params[:source])
handle_thumbnail_response(response)
end
private
def fetch_cached_thumbnail(source)
Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
Photos::Thumbnail.new(current_api_user, source, params[:id]).call
end
end
def handle_thumbnail_response(response)
if response.success?
send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok)
else
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
end
end
def integration_configured?
current_api_user.immich_integration_configured? || current_api_user.photoprism_integration_configured?
end
def check_integration_configured
unauthorized_integration unless integration_configured?
end
def check_source
unauthorized_integration unless params[:source] == 'immich' || params[:source] == 'photoprism'
end
def unauthorized_integration
render json: { error: "#{params[:source]&.capitalize} integration not configured" },
status: :unauthorized
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

@ -12,16 +12,11 @@ class Api::V1::SettingsController < ApiController
settings_params.each { |key, value| current_api_user.settings[key] = value }
if current_api_user.save
render json: {
message: 'Settings updated',
settings: current_api_user.settings,
status: 'success'
}, status: :ok
render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
status: :ok
else
render json: {
message: 'Something went wrong',
errors: current_api_user.errors.full_messages
}, status: :unprocessable_entity
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
status: :unprocessable_entity
end
end
@ -31,7 +26,8 @@ class Api::V1::SettingsController < ApiController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:preferred_map_layer, :points_rendering_mode, :live_map_enabled
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
)
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

@ -9,7 +9,8 @@ class ExportsController < ApplicationController
end
def create
export_name = "export_from_#{params[:start_at].to_date}_to_#{params[:end_at].to_date}.#{params[:file_format]}"
export_name =
"export_from_#{params[:start_at].to_date}_to_#{params[:end_at].to_date}.#{params[:file_format]}"
export = current_user.exports.create(name: export_name, status: :created)
ExportJob.perform_later(export.id, params[:start_at], params[:end_at], file_format: params[:file_format])

View file

@ -36,9 +36,7 @@ class MapController < ApplicationController
@distance ||= 0
@coordinates.each_cons(2) do
@distance += Geocoder::Calculations.distance_between(
[_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT
)
@distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]])
end
@distance.round(1)

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

@ -31,7 +31,7 @@ class SettingsController < ApplicationController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:immich_url, :immich_api_key
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
)
end
end

View file

@ -5,6 +5,9 @@ class StatsController < ApplicationController
def index
@stats = current_user.stats.group_by(&:year).sort.reverse
@points_total = current_user.tracked_points.count
@points_reverse_geocoded = current_user.total_reverse_geocoded_points
@points_reverse_geocoded_without_data = current_user.total_reverse_geocoded_points_without_data
end
def show
@ -13,7 +16,29 @@ class StatsController < ApplicationController
end
def update
Stats::CalculatingJob.perform_later(current_user.id)
if params[:month] == 'all'
(1..12).each do |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
redirect_to stats_path, notice: 'Stats are being updated', status: :see_other
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
class TripsController < ApplicationController
before_action :authenticate_user!
before_action :set_trip, only: %i[show edit update destroy]
before_action :set_coordinates, only: %i[show edit]
def index
@trips = current_user.trips.order(started_at: :desc).page(params[:page]).per(6)
end
def show
@coordinates = @trip.points.pluck(
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
@photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
@trip.photo_previews
end
@photo_sources = @trip.photo_sources
end
def new
@trip = Trip.new
@coordinates = []
end
def edit; end
def create
@trip = current_user.trips.build(trip_params)
if @trip.save
redirect_to @trip, notice: 'Trip was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
def update
if @trip.update(trip_params)
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@trip.destroy!
redirect_to trips_url, notice: 'Trip was successfully destroyed.', status: :see_other
end
private
def set_trip
@trip = current_user.trips.find(params[:id])
end
def set_coordinates
@coordinates = @trip.points.pluck(
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
end
def trip_params
params.require(:trip).permit(:name, :started_at, :ended_at, :notes)
end
end

View file

@ -11,13 +11,12 @@ class VisitsController < ApplicationController
visits = current_user
.visits
.where(status:)
.includes(%i[suggested_places area])
.order(started_at: order_by)
.group_by { |visit| visit.started_at.to_date }
.map { |k, v| { date: k, visits: v } }
@suggested_visits_count = current_user.visits.suggested.count
@visits = Kaminari.paginate_array(visits).page(params[:page]).per(10)
@visits = visits.page(params[:page]).per(10)
end
def update

View file

@ -101,9 +101,23 @@ module ApplicationHelper
'tab-active' if current_page?(link_path)
end
def active_visit_places_tab?(controller_name)
'tab-active' if current_page?(controller: controller_name)
end
def notification_link_color(notification)
return 'text-gray-600' if notification.read?
'text-blue-600'
end
def human_date(date)
date.strftime('%e %B %Y')
end
def speed_text_color(speed)
return 'text-default' if speed.to_i >= 0
'text-red-500'
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module TripsHelper
def immich_search_url(base_url, start_date, end_date)
query = {
takenAfter: "#{start_date.to_date}T00:00:00.000Z",
takenBefore: "#{end_date.to_date}T23:59:59.999Z"
}
encoded_query = URI.encode_www_form_component(query.to_json)
"#{base_url}/search?query=#{encoded_query}"
end
def photoprism_search_url(base_url, start_date, _end_date)
"#{base_url}/library/browse?view=cards&year=#{start_date.year}&month=#{start_date.month}&order=newest&public=true&quality=3"
end
def photo_search_url(source, settings, start_date, end_date)
case source
when 'immich'
immich_search_url(settings['immich_url'], start_date, end_date)
when 'photoprism'
photoprism_search_url(settings['photoprism_url'], start_date, end_date)
end
end
end

View file

@ -9,3 +9,6 @@ import "leaflet-providers"
import "chartkick"
import "Chart.bundle"
import "./channels"
import "trix"
import "@rails/actiontext"

View file

@ -0,0 +1,69 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["startedAt", "endedAt", "apiKey"]
static values = { tripsId: String }
connect() {
console.log("Datetime controller connected")
this.debounceTimer = null;
}
async updateCoordinates(event) {
// Clear any existing timeout
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Set new timeout
this.debounceTimer = setTimeout(async () => {
const startedAt = this.startedAtTarget.value
const endedAt = this.endedAtTarget.value
const apiKey = this.apiKeyTarget.value
if (startedAt && endedAt) {
try {
const params = new URLSearchParams({
start_at: startedAt,
end_at: endedAt,
api_key: apiKey,
slim: true
})
let allPoints = [];
let currentPage = 1;
const perPage = 1000;
do {
const paginatedParams = `${params}&page=${currentPage}&per_page=${perPage}`;
const response = await fetch(`/api/v1/points?${paginatedParams}`);
const data = await response.json();
allPoints = [...allPoints, ...data];
const totalPages = parseInt(response.headers.get('X-Total-Pages'));
currentPage++;
if (!totalPages || currentPage > totalPages) {
break;
}
} while (true);
const event = new CustomEvent('coordinates-updated', {
detail: { coordinates: allPoints },
bubbles: true,
composed: true
})
const tripsElement = document.querySelector('[data-controller="trips"]')
if (tripsElement) {
tripsElement.dispatchEvent(event)
} else {
console.error('Trips controller element not found')
}
} catch (error) {
console.error('Error:', error)
}
}
}, 500);
}
}

View file

@ -10,31 +10,20 @@ export default class extends Controller {
return
}
// console.log("Imports controller connected", {
// hasIndexTarget: this.hasIndexTarget,
// element: this.element,
// userId: this.element.dataset.userId
// });
this.setupSubscription();
}
setupSubscription() {
const userId = this.element.dataset.userId;
// console.log("Setting up subscription with userId:", userId);
this.channel = consumer.subscriptions.create(
{ channel: "ImportsChannel" },
{
connected: () => {
// console.log("Successfully connected to ImportsChannel");
// Test that we can receive messages
// console.log("Subscription object:", this.channel);
},
disconnected: () => {
// console.log("Disconnected from ImportsChannel");
},
received: (data) => {
// console.log("Received data:", data);
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
if (row) {

View file

@ -12,6 +12,7 @@ import { fetchAndDrawAreas } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas";
import { showFlashMessage } from "../maps/helpers";
import { fetchAndDisplayPhotos } from '../maps/helpers';
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
@ -31,6 +32,8 @@ export default class extends Controller {
settingsButtonAdded = false;
layerControl = null;
visitedCitiesCache = new Map();
trackedMonthsCache = null;
connect() {
console.log("Map controller connected");
@ -51,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);
@ -61,10 +64,12 @@ export default class extends Controller {
this.markersLayer = L.layerGroup(this.markersArray);
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
this.photoMarkers = L.layerGroup();
this.setupScratchLayer(this.countryCodesMap);
if (!this.settingsButtonAdded) {
@ -77,17 +82,17 @@ export default class extends Controller {
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer,
Areas: this.areasLayer // Add the areas layer to the controls
Areas: this.areasLayer,
Photos: this.photoMarkers
};
L.control
.scale({
position: "bottomright",
metric: true,
imperial: true,
maxWidth: 120,
})
.addTo(this.map);
// Add scale control to bottom right
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
maxWidth: 120
}).addTo(this.map)
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
@ -129,10 +134,34 @@ export default class extends Controller {
this.initializeDrawControl();
// Add event listeners to toggle draw controls
this.map.on('overlayadd', (e) => {
this.map.on('overlayadd', async (e) => {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
if (e.name === 'Photos') {
if (
(!this.userSettings.immich_url || !this.userSettings.immich_api_key) &&
(!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)
) {
showFlashMessage(
'error',
'Photos integration is not configured. Please check your integrations settings.'
);
return;
}
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
await fetchAndDisplayPhotos({
map: this.map,
photoMarkers: this.photoMarkers,
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
});
}
});
this.map.on('overlayremove', (e) => {
@ -144,9 +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();
}
@ -188,7 +245,8 @@ export default class extends Controller {
this.map,
this.timezone,
this.routeOpacity,
this.userSettings
this.userSettings,
this.distanceUnit
);
// Pan map to new location
@ -291,20 +349,22 @@ export default class extends Controller {
}
addEventListeners() {
this.handleDeleteClick = (event) => {
if (event.target && event.target.classList.contains('delete-point')) {
event.preventDefault();
const pointId = event.target.getAttribute('data-id');
// Create the handler only once and store it as an instance property
if (!this.handleDeleteClick) {
this.handleDeleteClick = (event) => {
if (event.target && event.target.classList.contains('delete-point')) {
event.preventDefault();
const pointId = event.target.getAttribute('data-id');
if (confirm('Are you sure you want to delete this point?')) {
this.deletePoint(pointId, this.apiKey);
if (confirm('Are you sure you want to delete this point?')) {
this.deletePoint(pointId, this.apiKey);
}
}
}
};
};
// Ensure only one listener is attached by removing any existing ones first
this.removeEventListeners();
document.addEventListener('click', this.handleDeleteClick);
// Add the listener only if it hasn't been added before
document.addEventListener('click', this.handleDeleteClick);
}
// Add an event listener for base layer change in Leaflet
this.map.on('baselayerchange', (event) => {
@ -347,23 +407,79 @@ export default class extends Controller {
return response.json();
})
.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
this.polylinesLayer = createPolylinesLayer(
this.markers,
this.map,
this.timezone,
this.routeOpacity,
this.userSettings,
this.distanceUnit
);
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);
const controlsLayer = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer,
Areas: this.areasLayer,
Photos: this.photoMarkers
};
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
}
// Update heatmap
this.heatmapLayer.setLatLngs(this.markers.map(marker => [marker[0], marker[1], 0.2]));
// Update fog if enabled
if (this.map.hasLayer(this.fogOverlay)) {
this.updateFog(this.markers, this.clearFogRadius);
}
})
.catch(error => {
console.error('There was a problem with the delete request:', error);
showFlashMessage('error', 'Failed to delete point');
});
}
removeMarker(id) {
const markerIndex = this.markersArray.findIndex(marker => marker.getPopup().getContent().includes(`data-id="${id}"`));
const numericId = parseInt(id);
const markerIndex = this.markersArray.findIndex(marker =>
marker.getPopup().getContent().includes(`data-id="${id}"`)
);
if (markerIndex !== -1) {
this.markersArray[markerIndex].remove(); // Assuming your marker object has a remove method
this.markersArray[markerIndex].remove();
this.markersArray.splice(markerIndex, 1);
this.markersLayer.clearLayers();
this.markersLayer.addLayer(L.layerGroup(this.markersArray));
// Remove from the markers data array
this.markers = this.markers.filter(marker => marker[6] !== parseInt(id));
this.markers = this.markers.filter(marker => {
const markerId = parseInt(marker[6]);
return markerId !== numericId;
});
}
}
@ -660,7 +776,7 @@ export default class extends Controller {
// Recreate layers only if they don't exist
this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings));
this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
this.areasLayer = preserveLayers.Areas || L.layerGroup();
@ -771,4 +887,429 @@ export default class extends Controller {
this.map.removeControl(this.layerControl);
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}
createPhotoMarker(photo) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`;
const icon = L.divIcon({
className: 'photo-marker',
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
iconSize: [48, 48]
});
const marker = L.marker(
[photo.exifInfo.latitude, photo.exifInfo.longitude],
{ icon }
);
const startOfDay = new Date(photo.localDateTime);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(photo.localDateTime);
endOfDay.setHours(23, 59, 59, 999);
const queryParams = {
takenAfter: startOfDay.toISOString(),
takenBefore: endOfDay.toISOString()
};
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
const immich_photo_link = `${this.userSettings.immich_url}/search?query=${encodedQuery}`;
const popupContent = `
<div class="max-w-xs">
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}"
class="w-8 h-8 mb-2 rounded"
style="transition: box-shadow 0.3s ease;"
alt="${photo.originalFileName}">
</a>
<h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
</div>
`;
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

@ -1,7 +1,28 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
timeout: Number
}
connect() {
if (this.timeoutValue) {
setTimeout(() => {
this.remove()
}, this.timeoutValue)
}
}
remove() {
this.element.remove()
this.element.classList.add('fade-out')
setTimeout(() => {
this.element.remove()
// Remove the container if it's empty
const container = document.getElementById('flash-messages')
if (container && !container.hasChildNodes()) {
container.remove()
}
}, 150)
}
}

View file

@ -0,0 +1,61 @@
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
export default class extends Controller {
static values = {
tripId: Number,
coordinates: Array,
apiKey: String,
userSettings: Object,
timezone: String,
distanceUnit: String
}
connect() {
setTimeout(() => {
this.initializeMap()
}, 100)
}
initializeMap() {
// Initialize map with basic configuration
this.map = L.map(this.element, {
zoomControl: false,
dragging: false,
scrollWheelZoom: false,
attributionControl: true // Disable default attribution control
})
// Add the tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
}).addTo(this.map)
// If we have coordinates, show the route
if (this.hasCoordinatesValue && this.coordinatesValue.length > 0) {
this.showRoute()
}
}
showRoute() {
const points = this.coordinatesValue.map(coord => [coord[0], coord[1]])
const polyline = L.polyline(points, {
color: 'blue',
opacity: 0.8,
weight: 3,
zIndexOffset: 400
}).addTo(this.map)
this.map.fitBounds(polyline.getBounds(), {
padding: [20, 20]
})
}
disconnect() {
if (this.map) {
this.map.remove()
}
}
}

View file

@ -0,0 +1,201 @@
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
import { osmMapLayer } from "../maps/layers"
import { createPopupContent } from "../maps/popups"
import { osmHotMapLayer } from "../maps/layers"
import { OPNVMapLayer } from "../maps/layers"
import { openTopoMapLayer } from "../maps/layers"
import { cyclOsmMapLayer } from "../maps/layers"
import { esriWorldStreetMapLayer } from "../maps/layers"
import { esriWorldTopoMapLayer } from "../maps/layers"
import { esriWorldImageryMapLayer } from "../maps/layers"
import { esriWorldGrayCanvasMapLayer } from "../maps/layers"
import { fetchAndDisplayPhotos } from '../maps/helpers';
import { showFlashMessage } from "../maps/helpers";
export default class extends Controller {
static targets = ["container", "startedAt", "endedAt"]
static values = { }
connect() {
if (!this.hasContainerTarget) {
return;
}
console.log("Trips controller connected")
this.coordinates = JSON.parse(this.containerTarget.dataset.coordinates)
this.apiKey = this.containerTarget.dataset.api_key
this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings)
this.timezone = this.containerTarget.dataset.timezone
this.distanceUnit = this.containerTarget.dataset.distance_unit
// Initialize map and layers
this.initializeMap()
// Add event listener for coordinates updates
this.element.addEventListener('coordinates-updated', (event) => {
console.log("Coordinates updated:", event.detail.coordinates)
this.updateMapWithCoordinates(event.detail.coordinates)
})
}
// Move map initialization to separate method
initializeMap() {
// Initialize layer groups
this.markersLayer = L.layerGroup()
this.polylinesLayer = L.layerGroup()
this.photoMarkers = L.layerGroup()
// Set default center and zoom for world view
const hasValidCoordinates = this.coordinates && Array.isArray(this.coordinates) && this.coordinates.length > 0
const center = hasValidCoordinates
? [this.coordinates[0][0], this.coordinates[0][1]]
: [20, 0] // Roughly centers the world map
const zoom = hasValidCoordinates ? 14 : 2
// Initialize map
this.map = L.map(this.containerTarget).setView(center, zoom)
// Add base map layer
osmMapLayer(this.map, "OpenStreetMap")
// Add scale control to bottom right
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
maxWidth: 120
}).addTo(this.map)
const overlayMaps = {
"Points": this.markersLayer,
"Route": this.polylinesLayer,
"Photos": this.photoMarkers
}
// Add layer control
L.control.layers(this.baseMaps(), overlayMaps).addTo(this.map)
// Add event listener for layer changes
this.map.on('overlayadd', (e) => {
if (e.name !== 'Photos') return;
if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) {
showFlashMessage(
'error',
'Photos integration is not configured. Please check your integrations settings.'
);
return;
}
if (!this.coordinates?.length) return;
const firstCoord = this.coordinates[0];
const lastCoord = this.coordinates[this.coordinates.length - 1];
const startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0];
const endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0];
fetchAndDisplayPhotos({
map: this.map,
photoMarkers: this.photoMarkers,
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
});
});
// Add markers and route
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}
}
disconnect() {
if (this.map) {
this.map.remove()
}
}
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
return {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
};
}
addMarkers() {
this.coordinates.forEach(coord => {
const marker = L.circleMarker(
[coord[0], coord[1]],
{
radius: 4,
color: coord[5] < 0 ? "orange" : "blue",
zIndexOffset: 1000
}
)
const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit)
marker.bindPopup(popupContent)
// Add to markers layer instead of directly to map
marker.addTo(this.markersLayer)
})
}
addPolyline() {
const points = this.coordinates.map(coord => [coord[0], coord[1]])
const polyline = L.polyline(points, {
color: 'blue',
opacity: 0.8,
weight: 3,
zIndexOffset: 400
})
// Add to polylines layer instead of directly to map
this.polylinesLayer.addTo(this.map)
polyline.addTo(this.polylinesLayer)
}
fitMapToBounds() {
const bounds = L.latLngBounds(
this.coordinates.map(coord => [coord[0], coord[1]])
)
this.map.fitBounds(bounds, { padding: [50, 50] })
}
// Add this new method to update coordinates and refresh the map
updateMapWithCoordinates(newCoordinates) {
// Transform the coordinates to match the expected format
this.coordinates = newCoordinates.map(point => [
parseFloat(point.latitude),
parseFloat(point.longitude),
point.id,
null, // This is so we can use the same order and position of elements in the coordinates object as in the api/v1/points response
(point.timestamp).toString()
]).sort((a, b) => a[4] - b[4]);
// Clear existing layers
this.markersLayer.clearLayers()
this.polylinesLayer.clearLayers()
this.photoMarkers.clearLayers()
// Add new markers and route if coordinates exist
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}
}
}

View file

@ -27,7 +27,15 @@ export default class extends Controller {
addMarkers() {
this.coordinates.forEach((coordinate) => {
L.circleMarker([coordinate[0], coordinate[1]], { radius: 4 }).addTo(this.map);
L.circleMarker(
[coordinate[0], coordinate[1]],
{
radius: 4,
color: coordinate[5] < 0 ? "orange" : "blue",
zIndexOffset: 1000
}
).addTo(this.map);
});
}
}

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') {
@ -136,3 +144,156 @@ function classesForFlash(type) {
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate, userSettings }, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000; // 3 seconds
// Create loading control
const LoadingControl = L.Control.extend({
onAdd: (map) => {
const container = L.DomUtil.create('div', 'leaflet-loading-control');
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
});
const loadingControl = new LoadingControl({ position: 'topleft' });
map.addControl(loadingControl);
try {
const params = new URLSearchParams({
api_key: apiKey,
start_date: startDate,
end_date: endDate
});
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`);
}
const photos = await response.json();
photoMarkers.clearLayers();
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
img.onload = () => {
createPhotoMarker(photo, userSettings, photoMarkers, apiKey);
resolve();
};
img.onerror = () => {
console.error(`Failed to load photo ${photo.id}`);
resolve(); // Resolve anyway to not block other photos
};
img.src = thumbnailUrl;
});
});
await Promise.all(photoLoadPromises);
if (!map.hasLayer(photoMarkers)) {
photoMarkers.addTo(map);
}
// Show checkmark for 1 second before removing
const loadingSpinner = document.querySelector('.loading-spinner');
loadingSpinner.classList.add('done');
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('Error fetching photos:', error);
showFlashMessage('error', 'Failed to fetch photos');
if (retryCount < MAX_RETRIES) {
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => {
fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate }, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
map.removeControl(loadingControl);
}
}
function getPhotoLink(photo, userSettings) {
switch (photo.source) {
case 'immich':
const startOfDay = new Date(photo.localDateTime);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(photo.localDateTime);
endOfDay.setHours(23, 59, 59, 999);
const queryParams = {
takenAfter: startOfDay.toISOString(),
takenBefore: endOfDay.toISOString()
};
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
return `${userSettings.immich_url}/search?query=${encodedQuery}`;
case 'photoprism':
return `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`;
default:
return '#'; // Default or error case
}
}
function getSourceUrl(photo, userSettings) {
switch (photo.source) {
case 'photoprism':
return userSettings.photoprism_url;
case 'immich':
return userSettings.immich_url;
default:
return '#'; // Default or error case
}
}
export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
if (!photo.latitude || !photo.longitude) return;
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
const icon = L.divIcon({
className: 'photo-marker',
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
iconSize: [48, 48]
});
const marker = L.marker(
[photo.latitude, photo.longitude],
{ icon }
);
const photo_link = getPhotoLink(photo, userSettings);
const source_url = getSourceUrl(photo, userSettings);
const popupContent = `
<div class="max-w-xs">
<a href="${photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}"
class="mb-2 rounded"
style="transition: box-shadow 0.3s ease;"
alt="${photo.originalFileName}">
</a>
<h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.city}, ${photo.state}, ${photo.country}</p>
<p>Source: <a href="${source_url}" target="_blank">${photo.source}</a></p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
</div>
`;
marker.bindPopup(popupContent);
photoMarkers.addLayer(marker);
}

View file

@ -8,7 +8,13 @@ export function createMarkersArray(markersData, userSettings) {
const [lat, lon] = marker;
const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit);
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
let markerColor = marker[5] < 0 ? "orange" : "blue";
return L.circleMarker([lat, lon], {
radius: 4,
color: markerColor,
zIndexOffset: 1000,
pane: 'markerPane'
}).bindPopup(popupContent, { autoClose: false });
});
}
}
@ -40,7 +46,11 @@ export function createSimplifiedMarkers(markersData) {
// Now create markers for the simplified data
return simplifiedMarkers.map((marker) => {
const [lat, lon] = marker;
const popupContent = this.createPopupContent(marker);
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
const popupContent = createPopupContent(marker);
let markerColor = marker[5] < 0 ? "orange" : "blue";
return L.circleMarker(
[lat, lon],
{ radius: 4, color: markerColor, zIndexOffset: 1000 }
).bindPopup(popupContent);
});
}

View file

@ -1,9 +1,10 @@
import { formatDate } from "../maps/helpers";
import { formatDistance } from "../maps/helpers";
import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
import { haversineDistance } from "../maps/helpers";
export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings) {
export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit) {
const originalStyle = { color: "blue", opacity: userSettings.routeOpacity, weight: 3 };
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
@ -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);
@ -33,7 +34,7 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
<strong>Start:</strong> ${firstTimestamp}<br>
<strong>End:</strong> ${lastTimestamp}<br>
<strong>Duration:</strong> ${timeOnRoute}<br>
<strong>Total Distance:</strong> ${formatDistance(totalDistance, userSettings.distanceUnit)}<br>
<strong>Total Distance:</strong> ${formatDistance(totalDistance, distanceUnit)}<br>
`;
if (isDebugMode) {
@ -90,7 +91,7 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
});
}
export function createPolylinesLayer(markers, map, userSettings) {
export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) {
const splitPolylines = [];
let currentPolyline = [];
const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
@ -121,9 +122,15 @@ export function createPolylinesLayer(markers, map, userSettings) {
return L.layerGroup(
splitPolylines.map((polylineCoordinates) => {
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
const polyline = L.polyline(latLngs, {
color: "blue",
opacity: 0.6,
weight: 3,
zIndexOffset: 400,
pane: 'overlayPane'
});
addHighlightOnHover(polyline, map, polylineCoordinates, userSettings);
addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit);
return polyline;
})

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>
`;
}

View file

@ -7,7 +7,7 @@ class BulkStatsCalculatingJob < ApplicationJob
user_ids = User.pluck(:id)
user_ids.each do |user_id|
Stats::CalculatingJob.perform_later(user_id)
Stats::BulkCalculator.new(user_id).call
end
end
end

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

15
app/jobs/cache/preheating_job.rb vendored Normal file
View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Cache::PreheatingJob < ApplicationJob
queue_as :default
def perform
User.find_each do |user|
Rails.cache.write(
"dawarich/user_#{user.id}_years_tracked",
user.years_tracked,
expires_in: 1.day
)
end
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

@ -7,6 +7,8 @@ class EnqueueBackgroundJob < ApplicationJob
case job_name
when 'start_immich_import'
Import::ImmichGeodataJob.perform_later(user_id)
when 'start_photoprism_import'
Import::PhotoprismGeodataJob.perform_later(user_id)
when 'start_reverse_geocoding', 'continue_reverse_geocoding'
Jobs::Create.new(job_name, user_id).call
else

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Import::PhotoprismGeodataJob < ApplicationJob
queue_as :imports
sidekiq_options retry: false
def perform(user_id)
user = User.find(user_id)
Photoprism::ImportGeodata.new(user).call
end
end

View file

@ -4,7 +4,7 @@ class ReverseGeocodingJob < ApplicationJob
queue_as :reverse_geocoding
def perform(klass, id)
return unless REVERSE_GEOCODING_ENABLED
return unless DawarichSettings.reverse_geocoding_enabled?
rate_limit_for_photon_api
@ -18,8 +18,8 @@ class ReverseGeocodingJob < ApplicationJob
end
def rate_limit_for_photon_api
return unless PHOTON_API_HOST == 'photon.komoot.io'
return unless DawarichSettings.photon_enabled?
sleep 1 if PHOTON_API_HOST == 'photon.komoot.io'
sleep 1 if DawarichSettings.photon_uses_komoot_io?
end
end

View file

@ -3,21 +3,24 @@
class Stats::CalculatingJob < ApplicationJob
queue_as :stats
def perform(user_id, start_at: nil, end_at: nil)
Stats::Calculate.new(user_id, start_at:, end_at:).call
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

@ -0,0 +1,14 @@
# frozen_string_literal: true
class TelemetrySendingJob < ApplicationJob
queue_as :default
def perform
return unless ENV['ENABLE_TELEMETRY'] == 'true'
data = Telemetry::Gather.new.call
Rails.logger.info("Telemetry data: #{data}")
Telemetry::Send.new(data).call
end
end

View file

@ -1,8 +1,6 @@
# frozen_string_literal: true
class Import < ApplicationRecord
# self.ignored_columns = %w[raw_data]
belongs_to :user
has_many :points, dependent: :destroy
@ -10,10 +8,21 @@ class Import < ApplicationRecord
enum :source, {
google_semantic_history: 0, owntracks: 1, google_records: 2,
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7
}
def process!
Imports::Create.new(user, self).call
end
def reverse_geocoded_points_count
points.reverse_geocoded.count
end
def years_and_months_tracked
points.order(:timestamp).pluck(:timestamp).map do |timestamp|
time = Time.zone.at(timestamp)
[time.year, time.month]
end.uniq
end
end

View file

@ -13,7 +13,7 @@ class Place < ApplicationRecord
enum :source, { manual: 0, photon: 1 }
def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
return unless DawarichSettings.reverse_geocoding_enabled?
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end

View file

@ -17,8 +17,8 @@ class Point < ApplicationRecord
}, suffix: true
enum :connection, { mobile: 0, wifi: 1, offline: 2, unknown: 4 }, suffix: true
scope :reverse_geocoded, -> { where.not(geodata: {}) }
scope :not_reverse_geocoded, -> { where(geodata: {}) }
scope :reverse_geocoded, -> { where.not(reverse_geocoded_at: nil) }
scope :not_reverse_geocoded, -> { where(reverse_geocoded_at: nil) }
scope :visited, -> { where.not(visit_id: nil) }
scope :not_visited, -> { where(visit_id: nil) }
@ -34,11 +34,15 @@ class Point < ApplicationRecord
end
def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
return unless DawarichSettings.reverse_geocoding_enabled?
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
def reverse_geocoded?
reverse_geocoded_at.present?
end
private
def broadcast_coordinates

View file

@ -6,62 +6,26 @@ class Stat < ApplicationRecord
belongs_to :user
def distance_by_day
timespan.to_a.map.with_index(1) do |day, index|
beginning_of_day = day.beginning_of_day.to_i
end_of_day = day.end_of_day.to_i
# We have to filter by user as well
points = user
.tracked_points
.without_raw_data
.order(timestamp: :asc)
.where(timestamp: beginning_of_day..end_of_day)
data = { day: index, distance: 0 }
points.each_cons(2) do |point1, point2|
distance = Geocoder::Calculations.distance_between(
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
)
data[:distance] += distance
end
[data[:day], data[:distance].round(2)]
end
monthly_points = points
calculate_daily_distances(monthly_points)
end
def self.year_distance(year, user)
stats = where(year:, user:).order(:month)
(1..12).to_a.map do |month|
month_stat = stats.select { |stat| stat.month == month }.first
stats_by_month = where(year:, user:).order(:month).index_by(&:month)
(1..12).map do |month|
month_name = Date::MONTHNAMES[month]
distance = month_stat&.distance || 0
distance = stats_by_month[month]&.distance || 0
[month_name, distance]
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 self.years
starting_year = select(:year).min&.year || Time.current.year
(starting_year..Time.current.year).to_a.reverse
def points
user.tracked_points
.without_raw_data
.where(timestamp: timespan)
.order(timestamp: :asc)
end
private
@ -69,4 +33,25 @@ class Stat < ApplicationRecord
def timespan
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
end
def calculate_daily_distances(monthly_points)
timespan.to_a.map.with_index(1) do |day, index|
daily_points = filter_points_for_day(monthly_points, day)
distance = calculate_distance(daily_points)
[index, distance.round(2)]
end
end
def filter_points_for_day(points, day)
beginning_of_day = day.beginning_of_day.to_i
end_of_day = day.end_of_day.to_i
points.select { |p| p.timestamp.between?(beginning_of_day, end_of_day) }
end
def calculate_distance(points)
points.each_cons(2).sum do |point1, point2|
DistanceCalculator.new(point1, point2).call
end
end
end

52
app/models/trip.rb Normal file
View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
class Trip < ApplicationRecord
has_rich_text :notes
belongs_to :user
validates :name, :started_at, :ended_at, presence: true
before_save :calculate_distance
def points
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
end
def countries
points.pluck(:country).uniq.compact
end
def photo_previews
@photo_previews ||= select_dominant_orientation(photos).sample(12)
end
def photo_sources
@photo_sources ||= photos.map { _1[:source] }.uniq
end
private
def photos
@photos ||= Trips::Photos.new(self, user).call
end
def select_dominant_orientation(photos)
vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' }
horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' }
# this is ridiculous, but I couldn't find my way around frontend
# to show all photos in the same height
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
end
def calculate_distance
distance = 0
points.each_cons(2) do |point1, point2|
distance += DistanceCalculator.new(point1, point2).call
end
self.distance = distance.round
end
end

View file

@ -1,10 +1,8 @@
# frozen_string_literal: true
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
:recoverable, :rememberable, :validatable, :trackable
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :imports, dependent: :destroy
@ -15,8 +13,10 @@ class User < ApplicationRecord
has_many :visits, dependent: :destroy
has_many :points, through: :imports
has_many :places, through: :visits
has_many :trips, dependent: :destroy
after_create :create_api_key
before_save :strip_trailing_slashes
def countries_visited
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
@ -48,8 +48,42 @@ class User < ApplicationRecord
cities_visited.size
end
def total_reverse_geocoded
tracked_points.select(:id).where.not(geodata: {}).count
def total_reverse_geocoded_points
tracked_points.where.not(reverse_geocoded_at: nil).count
end
def total_reverse_geocoded_points_without_data
tracked_points.where(geodata: {}).count
end
def immich_integration_configured?
settings['immich_url'].present? && settings['immich_api_key'].present?
end
def photoprism_integration_configured?
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
end
def years_tracked
Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
# Use select_all for better performance with large datasets
sql = <<-SQL
SELECT DISTINCT
EXTRACT(YEAR FROM TO_TIMESTAMP(timestamp)) AS year,
TO_CHAR(TO_TIMESTAMP(timestamp), 'Mon') AS month
FROM points
WHERE user_id = #{id}
ORDER BY year DESC, month ASC
SQL
result = ActiveRecord::Base.connection.select_all(sql)
result
.map { |r| [r['year'].to_i, r['month']] }
.group_by { |year, _| year }
.transform_values { |year_data| year_data.map { |_, month| month } }
.map { |year, months| { year: year, months: months } }
end
end
private
@ -59,4 +93,9 @@ class User < ApplicationRecord
save
end
def strip_trailing_slashes
settings['immich_url']&.gsub!(%r{/+\z}, '')
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
end
end

View file

@ -28,7 +28,9 @@ class Visit < ApplicationRecord
def default_radius
return area&.radius if area.present?
radius = points.map { Geocoder::Calculations.distance_between(center, [_1.latitude, _1.longitude]) }.max
radius = points.map do |point|
Geocoder::Calculations.distance_between(center, [point.latitude, point.longitude])
end.max
radius && radius >= 15 ? radius : 15
end
@ -38,7 +40,7 @@ class Visit < ApplicationRecord
end
def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
return unless DawarichSettings.reverse_geocoding_enabled?
return if place.blank?
ReverseGeocodingJob.perform_later('place', place_id)

View file

@ -0,0 +1,73 @@
# frozen_string_literal: true
class Api::PhotoSerializer
def initialize(photo, source)
@photo = photo.with_indifferent_access
@source = source
end
def call
{
id: id,
latitude: latitude,
longitude: longitude,
localDateTime: local_date_time,
originalFileName: original_file_name,
city: city,
state: state,
country: country,
type: type,
orientation: orientation,
source: source
}
end
private
attr_reader :photo, :source
def id
photo['id'] || photo['Hash']
end
def latitude
photo.dig('exifInfo', 'latitude') || photo['Lat']
end
def longitude
photo.dig('exifInfo', 'longitude') || photo['Lng']
end
def local_date_time
photo['localDateTime'] || photo['TakenAtLocal']
end
def original_file_name
photo['originalFileName'] || photo['OriginalName']
end
def city
photo.dig('exifInfo', 'city') || photo['PlaceCity']
end
def state
photo.dig('exifInfo', 'state') || photo['PlaceState']
end
def country
photo.dig('exifInfo', 'country') || photo['PlaceCountry']
end
def type
(photo['type'] || photo['Type']).downcase
end
def orientation
case source
when 'immich'
photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape'
when 'photoprism'
photo['Portrait'] ? 'portrait' : 'landscape'
end
end
end

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

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Cache::Clean
class << self
def call
Rails.logger.info('Cleaning cache...')
delete_control_flag
delete_version_cache
delete_years_tracked_cache
Rails.logger.info('Cache cleaned')
end
private
def delete_control_flag
Rails.cache.delete('cache_jobs_scheduled')
end
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

@ -0,0 +1,18 @@
# frozen_string_literal: true
class DistanceCalculator
def initialize(point1, point2)
@point1 = point1
@point2 = point2
end
def call
Geocoder::Calculations.distance_between(
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
)
end
private
attr_reader :point1, :point2
end

View file

@ -18,7 +18,7 @@ class Exports::Create
create_export_file(data)
export.update!(status: :completed, url: "exports/#{export.name}.#{file_format}")
export.update!(status: :completed, url: "exports/#{export.name}")
create_export_finished_notification
rescue StandardError => e
@ -74,10 +74,16 @@ class Exports::Create
def create_export_file(data)
dir_path = Rails.root.join('public/exports')
Dir.mkdir(dir_path) unless Dir.exist?(dir_path)
file_path = dir_path.join("#{export.name}.#{file_format}")
FileUtils.mkdir_p(dir_path) unless Dir.exist?(dir_path)
file_path = dir_path.join(export.name)
Rails.logger.info("Creating export file at: #{file_path}")
File.open(file_path, 'w') { |file| file.write(data) }
rescue StandardError => e
Rails.logger.error("Failed to create export file: #{e.message}")
raise
end
end

View file

@ -1,24 +1,19 @@
# frozen_string_literal: true
class Immich::ImportGeodata
attr_reader :user, :immich_api_base_url, :immich_api_key
attr_reader :user, :start_date, :end_date
def initialize(user)
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@immich_api_base_url = "#{user.settings['immich_url']}/api/search/metadata"
@immich_api_key = user.settings['immich_api_key']
@start_date = start_date
@end_date = end_date
end
def call
raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank?
raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank?
immich_data = retrieve_immich_data
log_no_data and return if immich_data.empty?
write_raw_data(immich_data)
immich_data_json = parse_immich_data(immich_data)
file_name = file_name(immich_data_json)
import = user.imports.find_or_initialize_by(name: file_name, source: :immich_api)
@ -27,53 +22,14 @@ class Immich::ImportGeodata
import.raw_data = immich_data_json
import.save!
ImportJob.perform_later(user.id, import.id)
end
private
def headers
{
'x-api-key' => immich_api_key,
'accept' => 'application/json'
}
end
def retrieve_immich_data
page = 1
data = []
max_pages = 1000 # Prevent infinite loop
while page <= max_pages
Rails.logger.debug "Retrieving next page: #{page}"
body = request_body(page)
response = JSON.parse(HTTParty.post(immich_api_base_url, headers: headers, body: body).body)
items = response.dig('assets', 'items')
Rails.logger.debug "#{items.size} items found"
break if items.empty?
data << items
Rails.logger.debug "next_page: #{response.dig('assets', 'nextPage')}"
page += 1
Rails.logger.debug "#{data.flatten.size} data size"
end
data.flatten
end
def request_body(page)
{
createdAfter: '1970-01-01',
size: 1000,
page: page,
order: 'asc',
withExif: true
}
Immich::RequestPhotos.new(user, start_date:, end_date:).call
end
def parse_immich_data(immich_data)
@ -101,13 +57,7 @@ class Immich::ImportGeodata
end
def log_no_data
Rails.logger.debug 'No data found'
end
def write_raw_data(immich_data)
File.open("tmp/imports/immich_raw_data_#{Time.current}_#{user.email}.json", 'w') do |file|
file.write(immich_data.to_json)
end
Rails.logger.info 'No geodata found for Immich'
end
def create_import_failed_notification(import_name)

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
class Immich::RequestPhotos
attr_reader :user, :immich_api_base_url, :immich_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata")
@immich_api_key = user.settings['immich_api_key']
@start_date = start_date
@end_date = end_date
end
def call
raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank?
raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank?
data = retrieve_immich_data
time_framed_data(data)
end
private
def retrieve_immich_data
page = 1
data = []
max_pages = 10_000 # Prevent infinite loop
# TODO: Handle pagination using nextPage
while page <= max_pages
response = JSON.parse(
HTTParty.post(
immich_api_base_url, headers: headers, body: request_body(page)
).body
)
Rails.logger.debug('==== IMMICH RESPONSE ====')
Rails.logger.debug(response)
items = response.dig('assets', 'items')
break if items.blank?
data << items
page += 1
end
data.flatten
end
def headers
{
'x-api-key' => immich_api_key,
'accept' => 'application/json'
}
end
def request_body(page)
body = {
takenAfter: start_date,
size: 1000,
page: page,
order: 'asc',
withExif: true
}
return body unless end_date
body.merge(takenBefore: end_date)
end
def time_framed_data(data)
data.select do |photo|
photo['localDateTime'] >= start_date &&
(end_date.nil? || photo['localDateTime'] <= end_date)
end
end
end

View file

@ -14,7 +14,7 @@ class Imports::Create
create_import_finished_notification(import, user)
schedule_stats_creating(user.id)
schedule_visit_suggesting(user.id, import)
# schedule_visit_suggesting(user.id, import) # Disabled until places & visits are reworked
rescue StandardError => e
create_import_failed_notification(import, user, e)
end
@ -24,20 +24,19 @@ class Imports::Create
def parser(source)
# Bad classes naming by the way, they are not parsers, they are point creators
case source
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser
when 'immich_api' then Immich::ImportParser
when 'geojson' then Geojson::ImportParser
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser
when 'geojson' then Geojson::ImportParser
when 'immich_api', 'photoprism_api' then Photos::ImportParser
end
end
def schedule_stats_creating(user_id)
start_at = import.points.order(:timestamp).first.recorded_at
end_at = import.points.order(:timestamp).last.recorded_at
Stats::CalculatingJob.perform_later(user_id, start_at:, end_at:)
import.years_and_months_tracked.each do |year, month|
Stats::CalculatingJob.perform_later(user_id, year, month)
end
end
def schedule_visit_suggesting(user_id, import)

View file

@ -4,10 +4,12 @@ class Imports::Watcher
class UnsupportedSourceError < StandardError; end
WATCHED_DIR_PATH = Rails.root.join('tmp/imports/watched')
SUPPORTED_FORMATS = %w[.gpx .json .rec].freeze
def call
user_directories.each do |user_email|
user = User.find_by(email: user_email)
next unless user
user_directory_path = File.join(WATCHED_DIR_PATH, user_email)
@ -35,9 +37,7 @@ class Imports::Watcher
end
def file_names(directory_path)
Dir.entries(directory_path).select do |file|
['.gpx', '.json'].include?(File.extname(file))
end
Dir.entries(directory_path).select { |file| SUPPORTED_FORMATS.include?(File.extname(file)) }
end
def process_file(user, directory_path, file_name)
@ -72,9 +72,19 @@ class Imports::Watcher
end
def source(file_name)
case file_name.split('.').last
when 'json' then :geojson
when 'gpx' then :gpx
case file_name.split('.').last.downcase
when 'json'
if file_name.match?(/location-history/i)
:google_phone_takeout
elsif file_name.match?(/Records/i)
:google_records
elsif file_name.match?(/\d{4}_\w+/i)
:google_semantic_history
else
:geojson
end
when 'rec' then :owntracks
when 'gpx' then :gpx
else raise UnsupportedSourceError, 'Unsupported source '
end
end
@ -82,6 +92,15 @@ class Imports::Watcher
def raw_data(file_path, source)
file = File.read(file_path)
source.to_sym == :gpx ? Hash.from_xml(file) : JSON.parse(file)
case source.to_sym
when :gpx
Hash.from_xml(file)
when :json, :geojson, :google_phone_takeout, :google_records, :google_semantic_history
JSON.parse(file)
when :owntracks
OwnTracks::RecParser.new(file).call
else
raise UnsupportedSourceError, "Unsupported source: #{source}"
end
end
end

View file

@ -21,6 +21,8 @@ class Jobs::Create
raise InvalidJobName, 'Invalid job name'
end
points.each(&:async_reverse_geocode)
points.find_each(batch_size: 1_000) do |point|
point.async_reverse_geocode
end
end
end

View file

@ -9,7 +9,12 @@ class OwnTracks::RecParser
def call
file.split("\n").map do |line|
JSON.parse(line.split("\t* \t")[1])
end
parts = line.split("\t")
if parts.size > 2 && parts[1].strip == '*'
JSON.parse(parts[2])
else
nil
end
end.compact
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Photoprism::CachePreviewToken
attr_reader :user, :preview_token
TOKEN_CACHE_KEY = 'dawarich/photoprism_preview_token'
def initialize(user, preview_token)
@user = user
@preview_token = preview_token
end
def call
Rails.cache.write("#{TOKEN_CACHE_KEY}_#{user.id}", preview_token)
end
end

View file

@ -0,0 +1,86 @@
# frozen_string_literal: true
class Photoprism::ImportGeodata
attr_reader :user, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@start_date = start_date
@end_date = end_date
end
def call
photoprism_data = retrieve_photoprism_data
return log_no_data if photoprism_data.empty?
json_data = parse_photoprism_data(photoprism_data)
create_and_process_import(json_data)
end
private
def create_and_process_import(json_data)
import = find_or_create_import(json_data)
return create_import_failed_notification(import.name) unless import.new_record?
import.update!(raw_data: json_data)
ImportJob.perform_later(user.id, import.id)
end
def find_or_create_import(json_data)
user.imports.find_or_initialize_by(
name: file_name(json_data),
source: :photoprism_api
)
end
def retrieve_photoprism_data
Photoprism::RequestPhotos.new(user, start_date:, end_date:).call
end
def parse_photoprism_data(photoprism_data)
geodata = photoprism_data.map do |asset|
next unless valid?(asset)
extract_geodata(asset)
end
geodata.compact.sort_by { |data| data[:timestamp] }
end
def valid?(asset)
asset['Lat'] &&
asset['Lat'] != 0 &&
asset['Lng'] &&
asset['Lng'] != 0 &&
asset['TakenAt']
end
def extract_geodata(asset)
{
latitude: asset['Lat'],
longitude: asset['Lng'],
timestamp: Time.zone.parse(asset['TakenAt']).to_i
}
end
def log_no_data
Rails.logger.info 'No geodata found for Photoprism'
end
def create_import_failed_notification(import_name)
Notifications::Create.new(
user:,
kind: :info,
title: 'Import was not created',
content: "Import with the same name (#{import_name}) already exists. If you want to proceed, delete the existing import and try again."
).call
end
def file_name(photoprism_data_json)
from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date
to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date
"photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json"
end
end

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
# This integration built based on
# [September 15, 2024](https://github.com/photoprism/photoprism/releases/tag/240915-e1280b2fb)
# release of Photoprism.
class Photoprism::RequestPhotos
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos")
@photoprism_api_key = user.settings['photoprism_api_key']
@start_date = start_date
@end_date = end_date
end
def call
raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank?
raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
data = retrieve_photoprism_data
return [] if data.blank? || data[0]['error'].present?
time_framed_data(data, start_date, end_date)
end
private
def retrieve_photoprism_data
data = []
offset = 0
while offset < 1_000_000
response_data = fetch_page(offset)
break if response_data.blank? || (response_data.is_a?(Hash) && response_data.try(:[], 'error').present?)
data << response_data
offset += 1000
end
data.flatten
end
def fetch_page(offset)
response = HTTParty.get(
photoprism_api_base_url,
headers: headers,
query: request_params(offset)
)
if response.code != 200
Rails.logger.error "Photoprism API returned #{response.code}: #{response.body}"
Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}"
end
cache_preview_token(response.headers)
JSON.parse(response.body)
end
def headers
{
'Authorization' => "Bearer #{photoprism_api_key}",
'accept' => 'application/json'
}
end
def request_params(offset = 0)
params = offset.zero? ? default_params : default_params.merge(offset: offset)
params[:before] = end_date if end_date.present?
params
end
def default_params
{
q: '',
public: true,
quality: 3,
after: start_date,
count: 1000
}
end
def time_framed_data(data, start_date, end_date)
data.flatten.select do |photo|
taken_at = DateTime.parse(photo['TakenAtLocal'])
end_date ||= Time.current
taken_at.between?(start_date.to_datetime, end_date.to_datetime)
end
end
def cache_preview_token(headers)
preview_token = headers['X-Preview-Token']
Photoprism::CachePreviewToken.new(user, preview_token).call
end
end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Immich::ImportParser
class Photos::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
class Photos::Search
attr_reader :user, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@start_date = start_date
@end_date = end_date
end
def call
photos = []
photos << request_immich if user.immich_integration_configured?
photos << request_photoprism if user.photoprism_integration_configured?
photos.flatten.map { |photo| Api::PhotoSerializer.new(photo, photo[:source]).call }
end
private
def request_immich
Immich::RequestPhotos.new(
user,
start_date: start_date,
end_date: end_date
).call.map { |asset| transform_asset(asset, 'immich') }.compact
end
def request_photoprism
Photoprism::RequestPhotos.new(
user,
start_date: start_date,
end_date: end_date
).call.map { |asset| transform_asset(asset, 'photoprism') }.compact
end
def transform_asset(asset, source)
asset_type = asset['type'] || asset['Type']
return if asset_type.downcase == 'video'
asset.merge(source: source)
end
end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
class Photos::Thumbnail
def initialize(user, source, id)
@user = user
@source = source
@id = id
end
def call
HTTParty.get(request_url, headers: headers)
end
private
attr_reader :user, :source, :id
def source_url
user.settings["#{source}_url"]
end
def source_api_key
user.settings["#{source}_api_key"]
end
def source_path
case source
when 'immich'
"/api/assets/#{id}/thumbnail?size=preview"
when 'photoprism'
preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
"/api/v1/t/#{id}/#{preview_token}/tile_500"
else
raise "Unsupported source: #{source}"
end
end
def request_url
"#{source_url}#{source_path}"
end
def headers
request_headers = {
'accept' => 'application/octet-stream'
}
request_headers['X-Api-Key'] = source_api_key if source == 'immich'
request_headers
end
end

View file

@ -12,8 +12,8 @@ class ReverseGeocoding::Places::FetchData
end
def call
if ::PHOTON_API_HOST.blank?
Rails.logger.warn('PHOTON_API_HOST is not set')
unless DawarichSettings.reverse_geocoding_enabled?
Rails.logger.warn('Reverse geocoding is not enabled')
return
end

View file

@ -10,17 +10,22 @@ class ReverseGeocoding::Points::FetchData
end
def call
return if reverse_geocoded?
return if point.reverse_geocoded?
response = Geocoder.search([point.latitude, point.longitude]).first
return if response.blank? || response.data['error'].present?
point.update!(city: response.city, country: response.country, geodata: response.data)
update_point_with_geocoding_data
end
private
def reverse_geocoded?
point.geodata.present?
def update_point_with_geocoding_data
response = Geocoder.search([point.latitude, point.longitude]).first
return if response.blank? || response.data['error'].present?
point.update!(
city: response.city,
country: response.country,
geodata: response.data,
reverse_geocoded_at: Time.current
)
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Stats
class BulkCalculator
def initialize(user_id)
@user_id = user_id
end
def call
months = extract_months(fetch_timestamps)
schedule_calculations(months)
end
private
attr_reader :user_id
def fetch_timestamps
last_calculated_at = Stat.where(user_id:).maximum(:updated_at)
last_calculated_at ||= DateTime.new(1970, 1, 1)
time_diff = last_calculated_at.to_i..Time.current.to_i
Point.where(user_id:, timestamp: time_diff).pluck(:timestamp)
end
def extract_months(timestamps)
timestamps.group_by do |timestamp|
time = Time.zone.at(timestamp)
[time.year, time.month]
end.keys
end
def schedule_calculations(months)
months.each do |year, month|
Stats::CalculatingJob.perform_later(user_id, year, month)
end
end
end
end

View file

@ -1,69 +0,0 @@
# frozen_string_literal: true
class Stats::Calculate
def initialize(user_id, start_at: nil, end_at: nil)
@user = User.find(user_id)
@start_at = start_at || DateTime.new(1970, 1, 1)
@end_at = end_at || Time.current
end
def call
points = points(start_timestamp, end_timestamp)
points_by_month = points.group_by_month(&:recorded_at)
points_by_month.each do |month, month_points|
update_month_stats(month_points, month.year, month.month)
end
rescue StandardError => e
create_stats_update_failed_notification(user, e)
end
private
attr_reader :user, :start_at, :end_at
def start_timestamp = start_at.to_i
def end_timestamp = end_at.to_i
def update_month_stats(month_points, year, month)
return if month_points.empty?
stat = current_stat(year, month)
distance_by_day = stat.distance_by_day
stat.daily_distance = distance_by_day
stat.distance = distance(distance_by_day)
stat.toponyms = toponyms(month_points)
stat.save
end
def points(start_at, end_at)
user
.tracked_points
.without_raw_data
.where(timestamp: start_at..end_at)
.order(:timestamp)
.select(:latitude, :longitude, :timestamp, :city, :country)
end
def distance(distance_by_day)
distance_by_day.sum { |day| day[1] }
end
def toponyms(points)
CountriesAndCities.new(points).call
end
def current_stat(year, month)
Stat.find_or_initialize_by(year:, month:, user:)
end
def create_stats_update_failed_notification(user, error)
Notifications::Create.new(
user:,
kind: :error,
title: 'Stats update failed',
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call
end
end

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
class Stats::CalculateMonth
def initialize(user_id, year, month)
@user = User.find(user_id)
@year = year.to_i
@month = month.to_i
end
def call
return if points.empty?
update_month_stats(year, month)
rescue StandardError => e
create_stats_update_failed_notification(user, e)
end
private
attr_reader :user, :year, :month
def start_timestamp = DateTime.new(year, month, 1).to_i
def end_timestamp
DateTime.new(year, month, -1).to_i # -1 returns last day of month
end
def update_month_stats(year, month)
Stat.transaction do
stat = Stat.find_or_initialize_by(year:, month:, user:)
distance_by_day = stat.distance_by_day
stat.assign_attributes(
daily_distance: distance_by_day,
distance: distance(distance_by_day),
toponyms: toponyms
)
stat.save
end
end
def points
return @points if defined?(@points)
@points = user
.tracked_points
.without_raw_data
.where(timestamp: start_timestamp..end_timestamp)
.select(:latitude, :longitude, :timestamp, :city, :country)
.order(timestamp: :asc)
end
def distance(distance_by_day)
distance_by_day.sum { |day| day[1] }
end
def toponyms
CountriesAndCities.new(points).call
end
def create_stats_update_failed_notification(user, error)
Notifications::Create.new(
user:,
kind: :error,
title: 'Stats update failed',
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call
end
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
class Telemetry::Gather
def initialize(measurement: 'dawarich_usage_metrics')
@measurement = measurement
end
def call
{
measurement:,
timestamp: Time.current.to_i,
tags: { instance_id: },
fields: { dau:, app_version: }
}
end
private
attr_reader :measurement
def instance_id
@instance_id ||= Digest::SHA2.hexdigest(User.first.api_key)
end
def app_version
"\"#{APP_VERSION}\""
end
def dau
User.where(last_sign_in_at: Time.zone.today.beginning_of_day..Time.zone.today.end_of_day).count
end
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Telemetry::Send
BUCKET = 'dawarich_metrics'
ORG = 'monitoring'
def initialize(payload)
@payload = payload
end
def call
return unless ENV['ENABLE_TELEMETRY'] == 'true'
line_protocol = build_line_protocol
response = send_request(line_protocol)
handle_response(response)
end
private
attr_reader :payload
def build_line_protocol
tag_string = payload[:tags].map { |k, v| "#{k}=#{v}" }.join(',')
field_string = payload[:fields].map { |k, v| "#{k}=#{v}" }.join(',')
"#{payload[:measurement]},#{tag_string} #{field_string} #{payload[:timestamp].to_i}"
end
def send_request(line_protocol)
HTTParty.post(
"#{TELEMETRY_URL}?org=#{ORG}&bucket=#{BUCKET}&precision=s",
body: line_protocol,
headers: {
'Authorization' => "Token #{Base64.decode64(TELEMETRY_STRING)}",
'Content-Type' => 'text/plain'
}
)
end
def handle_response(response)
Rails.logger.error("InfluxDB write failed: #{response.body}") unless response.success?
response
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
class Trips::Photos
def initialize(trip, user)
@trip = trip
@user = user
end
def call
return [] unless can_fetch_photos?
photos
end
private
attr_reader :trip, :user
def can_fetch_photos?
user.immich_integration_configured? || user.photoprism_integration_configured?
end
def photos
return @photos if defined?(@photos)
photos = Photos::Search.new(
user,
start_date: trip.started_at.to_date.to_s,
end_date: trip.ended_at.to_date.to_s
).call
@photos = photos.map { |photo| photo_thumbnail(photo) }
end
def photo_thumbnail(asset)
{
id: asset[:id],
url: "/api/v1/photos/#{asset[:id]}/thumbnail.jpg?api_key=#{user.api_key}&source=#{asset[:source]}",
source: asset[:source],
orientation: asset[:orientation]
}
end
end

View file

@ -20,7 +20,7 @@ class Visits::Suggest
create_visits_notification(user)
return nil unless reverse_geocoding_enabled?
return nil unless DawarichSettings.reverse_geocoding_enabled?
reverse_geocode(visits)
end
@ -68,10 +68,6 @@ class Visits::Suggest
visits.each(&:async_reverse_geocode)
end
def reverse_geocoding_enabled?
::REVERSE_GEOCODING_ENABLED && ::PHOTON_API_HOST.present?
end
def create_visits_notification(user)
content = <<~CONTENT
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="#{visits_path}" class="link">Visits</a> page.

View file

@ -0,0 +1,14 @@
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
<% if blob.representable? %>
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
<% end %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure>

View file

@ -2,7 +2,7 @@
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Register now!</h1>
<p class="py-6">And change this text!</p>
<p class="py-6">and take control over your location data.</p>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body ') do |f| %>

View file

@ -1,8 +1,13 @@
<div class="hero min-h-content bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Login now!</h1>
<p class="py-6">And change this text!</p>
<h1 class="text-5xl font-bold">Login now</h1>
<p class="py-6">and take control over your location data.</p>
<% if ENV['DEMO_ENV'] == 'true' %>
<p class="py-6">
Demo account: <strong class="text-success">demo@dawarich.app</strong> / password: <strong class="text-success">password</strong>
</p>
<% end %>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body ') do |f| %>

View file

@ -1,7 +1,7 @@
<% content_for :title, "Exports" %>
<div class="w-full">
<div class="flex justify-center my-5">
<div class="w-full my-5">
<div class="flex justify-center">
<h1 class="font-bold text-4xl">Exports</h1>
</div>

View file

@ -1,4 +1,4 @@
<div class="w-full mx-auto">
<div class="w-full mx-auto my-5">
<div class="flex justify-between items-center mt-5 mb-5">
<div class="hero h-fit bg-base-200 py-20" style="background-image: url(<%= '/images/bg-image.jpg' %>);">
<div class="hero-content text-center">

View file

@ -1,7 +1,7 @@
<% content_for :title, 'Imports' %>
<div class="w-full">
<div class="flex justify-between items-center mb-3">
<div class="w-full my-5">
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Imports</h1>
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
@ -10,6 +10,11 @@
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
<% end %>
<% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %>
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Photoprism instance data in the Settings">Import Photoprism data</a>
<% end %>
</div>
<div id="imports" class="min-w-full">
@ -36,6 +41,7 @@
<tr>
<th>Name</th>
<th>Imported points</th>
<th>Reverse geocoded points</th>
<th>Created at</th>
</tr>
</thead>
@ -45,7 +51,9 @@
data-user-id="<%= current_user.id %>"
>
<% @imports.each do |import| %>
<tr data-import-id="<%= import.id %>" id="import-<%= import.id %>">
<tr data-import-id="<%= import.id %>"
id="import-<%= import.id %>"
data-points-total="<%= import.points_count %>">
<td>
<%= link_to import.name, import, class: 'underline hover:no-underline' %>
(<%= import.source %>)
@ -57,6 +65,9 @@
<td data-points-count>
<%= number_with_delimiter import.points_count %>
</td>
<td data-reverse-geocoded-points-count>
<%= number_with_delimiter import.reverse_geocoded_points_count %>
</td>
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
</tr>
<% end %>

View file

@ -0,0 +1,3 @@
<div class="trix-content">
<%= yield %>
</div>

View file

@ -1,40 +1,42 @@
<% content_for :title, 'Map' %>
<div class="flex flex-col lg:flex-row lg:space-x-4 mt-8 w-full">
<div class='w-full lg:w-5/6'>
<div class="flex flex-col lg:flex-row lg:space-x-4 my-5 w-full">
<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">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
</div>
</div>
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-3/12">
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12">
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
<%= link_to "Yesterday",
map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]),
class: "btn btn-neutral hover:btn-ghost" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
</div>
</div>
</div>
@ -42,7 +44,7 @@
<div
id='map'
class="w-full"
class="w-full z-0"
data-controller="maps points"
data-points-target="map"
data-distance_unit="<%= DISTANCE_UNIT %>"
@ -50,16 +52,12 @@
data-user_settings=<%= current_user.settings.to_json %>
data-coordinates="<%= @coordinates %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-maps-target="container" class="h-[25rem] w-full min-h-screen">
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen">
<div id="fog" class="fog"></div>
</div>
</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,8 +1,9 @@
<% content_for :title, "Places" %>
<div class="w-full">
<div class="flex justify-center my-5">
<h1 class="font-bold text-4xl">Places</h1>
<div class="w-full my-5">
<div role="tablist" class="tabs tabs-lifted tabs-lg">
<%= link_to 'Visits', visits_path(status: :confirmed), role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('visits')}" %>
<%= link_to 'Places', places_path, role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('places')}" %>
</div>
<div id="places" class="min-w-full">

View file

@ -13,6 +13,7 @@
}
%>
</td>
<td class='<%= speed_text_color(point.velocity) %>'><%= point.velocity %></td>
<td><%= point.recorded_at %></td>
<td><%= point.latitude %>, <%= point.longitude %></td>
<td></td>

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