Merge branch 'dev', remote-tracking branch 'origin' into feature/follow-up-emails

This commit is contained in:
Eugene Burmakin
2025-09-13 14:03:04 +02:00
134 changed files with 4308 additions and 1589 deletions

View File

@@ -1 +1 @@
0.31.0 0.31.1

View File

@@ -71,9 +71,21 @@ jobs:
TAGS="freikin/dawarich:${VERSION}" TAGS="freikin/dawarich:${VERSION}"
# Set platforms based on release type # Set platforms based on version type and release type
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6" PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
# Check if this is a patch version (x.y.z where z > 0)
if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then
echo "Detected patch version ($VERSION) - building for AMD64 only"
PLATFORMS="linux/amd64"
elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
echo "Detected minor version ($VERSION) - building for all platforms"
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
else
echo "Version format not recognized or non-semver - using AMD64 only for safety"
PLATFORMS="linux/amd64"
fi
# Add :rc tag for pre-releases # Add :rc tag for pre-releases
if [ "${{ github.event.release.prerelease }}" = "true" ]; then if [ "${{ github.event.release.prerelease }}" = "true" ]; then
TAGS="${TAGS},freikin/dawarich:rc" TAGS="${TAGS},freikin/dawarich:rc"

View File

@@ -6,16 +6,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# [UNRELEASED] # [UNRELEASED]
## Changed
- Stats page now loads significantly faster due to caching
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
## Fixed ## Fixed
- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466 - Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
- Stats are now being calculated for trial users as well as active ones. - Stats are now being calculated for trial users as well as active ones.
## Added
- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
- A new month stat page, featuring insights on how user's month went: distance traveled, active days, countries visited and more.
- Month stat page can now be shared via public link. User can limit access to the page by sharing period: 1/12/24 hours or permanent.
## Changed
- Stats page now loads significantly faster due to caching
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
- Minor versions are now being built only for amd64 architecture to speed up the build process.
- If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error.
# [0.31.0] - 2025-09-04 # [0.31.0] - 2025-09-04
The Search release The Search release

11
Gemfile
View File

@@ -7,9 +7,9 @@ ruby File.read('.ruby-version').strip
gem 'activerecord-postgis-adapter' gem 'activerecord-postgis-adapter'
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40 # https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'aws-sdk-core', '~> 3.215.1', require: false gem 'aws-sdk-core', '~> 3.215.1', require: false
gem 'aws-sdk-kms', '~> 1.96.0', require: false gem 'aws-sdk-kms', '~> 1.96.0', require: false
gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'bootsnap', require: false gem 'bootsnap', require: false
gem 'chartkick' gem 'chartkick'
gem 'data_migrate' gem 'data_migrate'
@@ -19,37 +19,38 @@ gem 'gpx'
gem 'groupdate' gem 'groupdate'
gem 'httparty' gem 'httparty'
gem 'importmap-rails' gem 'importmap-rails'
gem 'jwt'
gem 'kaminari' gem 'kaminari'
gem 'lograge' gem 'lograge'
gem 'oj' gem 'oj'
gem 'parallel' gem 'parallel'
gem 'pg' gem 'pg'
gem 'prometheus_exporter' gem 'prometheus_exporter'
gem 'rqrcode', '~> 3.0'
gem 'puma' gem 'puma'
gem 'pundit' gem 'pundit'
gem 'rails', '~> 8.0' gem 'rails', '~> 8.0'
gem 'rails_icons'
gem 'redis' gem 'redis'
gem 'rexml' gem 'rexml'
gem 'rgeo' gem 'rgeo'
gem 'rgeo-activerecord' gem 'rgeo-activerecord'
gem 'rgeo-geojson' gem 'rgeo-geojson'
gem 'rqrcode', '~> 3.0'
gem 'rswag-api' gem 'rswag-api'
gem 'rswag-ui' gem 'rswag-ui'
gem 'rubyzip', '~> 2.4' gem 'rubyzip', '~> 2.4'
gem 'sentry-ruby'
gem 'sentry-rails' gem 'sentry-rails'
gem 'stackprof' gem 'sentry-ruby'
gem 'sidekiq' gem 'sidekiq'
gem 'sidekiq-cron' gem 'sidekiq-cron'
gem 'sidekiq-limit_fetch' gem 'sidekiq-limit_fetch'
gem 'sprockets-rails' gem 'sprockets-rails'
gem 'stackprof'
gem 'stimulus-rails' gem 'stimulus-rails'
gem 'strong_migrations' gem 'strong_migrations'
gem 'tailwindcss-rails' gem 'tailwindcss-rails'
gem 'turbo-rails' gem 'turbo-rails'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
gem 'jwt'
group :development, :test do group :development, :test do
gem 'brakeman', require: false gem 'brakeman', require: false

View File

@@ -130,7 +130,7 @@ GEM
chunky_png (1.4.0) chunky_png (1.4.0)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.3) connection_pool (2.5.4)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@@ -172,7 +172,8 @@ GEM
railties (>= 6.1.0) railties (>= 6.1.0)
fakeredis (0.1.4) fakeredis (0.1.4)
ffaker (2.24.0) ffaker (2.24.0)
foreman (0.88.1) foreman (0.90.0)
thor (~> 1.4)
fugit (1.11.1) fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
@@ -191,7 +192,7 @@ GEM
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.1.0) importmap-rails (2.2.2)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
@@ -304,7 +305,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.16) rack (3.2.0)
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@@ -333,6 +334,9 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
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) 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)
rails_icons (1.4.0)
nokogiri (~> 1.16, >= 1.16.4)
rails (> 6.1)
railties (8.0.2.1) railties (8.0.2.1)
actionpack (= 8.0.2.1) actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1) activesupport (= 8.0.2.1)
@@ -421,11 +425,11 @@ GEM
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rubyzip (2.4.1) rubyzip (2.4.1)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.33.0) selenium-webdriver (4.35.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (5.26.0) sentry-rails (5.26.0)
railties (>= 5.0) railties (>= 5.0)
@@ -553,6 +557,7 @@ DEPENDENCIES
puma puma
pundit pundit
rails (~> 8.0) rails (~> 8.0)
rails_icons
redis redis
rexml rexml
rgeo rgeo

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

View File

@@ -92,7 +92,7 @@
} }
.loading-spinner::before { .loading-spinner::before {
content: '🔵'; content: '';
font-size: 18px; font-size: 18px;
animation: spinner 1s linear infinite; animation: spinner 1s linear infinite;
} }

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bell-icon lucide-bell"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@@ -0,0 +1,23 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 10h.01" />
<path d="M12 14h.01" />
<path d="M12 6h.01" />
<path d="M16 10h.01" />
<path d="M16 14h.01" />
<path d="M16 6h.01" />
<path d="M8 10h.01" />
<path d="M8 14h.01" />
<path d="M8 6h.01" />
<path d="M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
<rect x="4" y="2" width="16" height="20" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 6v6" />
<path d="M15 6v6" />
<path d="M2 12h19.6" />
<path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2 0-.4-.1-.8-.2-1.2l-1.4-5C20.1 6.8 19.1 6 18 6H4a2 2 0 0 0-2 2v10h3" />
<circle cx="7" cy="18" r="2" />
<path d="M9 18h5" />
<circle cx="16" cy="18" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8" />
<path d="M3 10h18" />
<path d="m16 20 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" />
<circle cx="12" cy="13" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2" />
<circle cx="7" cy="17" r="2" />
<path d="M9 17h6" />
<circle cx="17" cy="17" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17" />
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-flower-icon lucide-flower"><circle cx="12" cy="12" r="3"/><path d="M12 16.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 1 1 4.5 4.5 4.5 4.5 0 1 1-4.5 4.5"/><path d="M12 7.5V9"/><path d="M7.5 12H9"/><path d="M16.5 12H15"/><path d="M12 16.5V15"/><path d="m8 8 1.88 1.88"/><path d="M14.12 9.88 16 8"/><path d="m8 16 1.88-1.88"/><path d="M14.12 14.12 16 16"/></svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-leaf-icon lucide-leaf"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>

After

Width:  |  Height:  |  Size: 384 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
<path d="M9 18h6" />
<path d="M10 22h4" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19.914 11.105A7.298 7.298 0 0 0 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32 32 0 0 0 .824-.738" />
<circle cx="12" cy="10" r="3" />
<path d="M16 18h6" />
<path d="M19 15v6" />
</svg>

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 19-1.106-.552a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0l4.212 2.106a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619V12" />
<path d="M15 5.764V12" />
<path d="M18 15v6" />
<path d="M21 18h-6" />
<path d="M9 3.236v15" />
</svg>

After

Width:  |  Height:  |  Size: 513 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" />
<path d="M15 5.764v15" />
<path d="M9 3.236v15" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-ccw-icon lucide-refresh-ccw"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v13" />
<path d="m16 6-4-4-4 4" />
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
</svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="8" cy="21" r="1" />
<circle cx="19" cy="21" r="1" />
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" />
</svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -0,0 +1,24 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m10 20-1.25-2.5L6 18" />
<path d="M10 4 8.75 6.5 6 6" />
<path d="m14 20 1.25-2.5L18 18" />
<path d="m14 4 1.25 2.5L18 6" />
<path d="m17 21-3-6h-4" />
<path d="m17 3-3 6 1.5 3" />
<path d="M2 12h6.5L10 9" />
<path d="m20 10-1.5 2 1.5 2" />
<path d="M22 12h-6.5L14 15" />
<path d="m4 10 1.5 2L4 14" />
<path d="m7 21 3-6-1.5-3" />
<path d="m7 3 3 6h4" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

After

Width:  |  Height:  |  Size: 588 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tree-palm-icon lucide-tree-palm"><path d="M13 8c0-2.76-2.46-5-5.5-5S2 5.24 2 8h2l1-1 1 1h4"/><path d="M13 7.14A5.82 5.82 0 0 1 16.5 6c3.04 0 5.5 2.24 5.5 5h-3l-1-1-1 1h-3"/><path d="M5.89 9.71c-2.15 2.15-2.3 5.47-.35 7.43l4.24-4.25.7-.7.71-.71 2.12-2.12c-1.95-1.96-5.27-1.8-7.42.35"/><path d="M11 15.5c.5 2.5-.17 4.5-1 6.5h4c2-5.5-.5-12-1-14"/></svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 7h6v6" />
<path d="m22 7-8.5 8.5-5-5L2 17" />
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978" />
<path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978" />
<path d="M18 9h1.5a1 1 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z" />
<path d="M6 9H4.5a1 1 0 0 1 0-5H6" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

View File

@@ -15,7 +15,7 @@ class Api::V1::AreasController < ApiController
if @area.save if @area.save
render json: @area, status: :created render json: @area, status: :created
else else
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
end end
end end
@@ -23,7 +23,7 @@ class Api::V1::AreasController < ApiController
if @area.update(area_params) if @area.update(area_params)
render json: @area, status: :ok render json: @area, status: :ok
else else
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
end end
end end

View File

@@ -0,0 +1,115 @@
# frozen_string_literal: true
class Api::V1::Maps::HexagonsController < ApiController
skip_before_action :authenticate_api_key, if: :public_sharing_request?
before_action :validate_bbox_params, except: [:bounds]
before_action :set_user_and_dates
def index
service = Maps::HexagonGrid.new(hexagon_params)
result = service.call
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
render json: result
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
Maps::HexagonGrid::InvalidCoordinatesError => e
render json: { error: e.message }, status: :bad_request
rescue Maps::HexagonGrid::PostGISError => e
render json: { error: e.message }, status: :internal_server_error
rescue StandardError => _e
handle_service_error
end
def bounds
# Get the bounding box of user's points for the date range
return render json: { error: 'No user found' }, status: :not_found unless @target_user
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
points_relation = @target_user.points.where(timestamp: @start_date..@end_date)
point_count = points_relation.count
if point_count.positive?
bounds_result = ActiveRecord::Base.connection.exec_query(
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
MIN(longitude) as min_lng, MAX(longitude) as max_lng
FROM points
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3",
'bounds_query',
[@target_user.id, @start_date.to_i, @end_date.to_i]
).first
render json: {
min_lat: bounds_result['min_lat'].to_f,
max_lat: bounds_result['max_lat'].to_f,
min_lng: bounds_result['min_lng'].to_f,
max_lng: bounds_result['max_lng'].to_f,
point_count: point_count
}
else
render json: {
error: 'No data found for the specified date range',
point_count: 0
}, status: :not_found
end
end
private
def bbox_params
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
end
def hexagon_params
bbox_params.merge(
user_id: @target_user&.id,
start_date: @start_date,
end_date: @end_date
)
end
def set_user_and_dates
return set_public_sharing_context if params[:uuid].present?
set_authenticated_context
end
def set_public_sharing_context
@stat = Stat.find_by(sharing_uuid: params[:uuid])
unless @stat&.public_accessible?
render json: {
error: 'Shared stats not found or no longer available'
}, status: :not_found and return
end
@target_user = @stat.user
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day
@end_date = @start_date.end_of_month.end_of_day
end
def set_authenticated_context
@target_user = current_api_user
@start_date = params[:start_date]
@end_date = params[:end_date]
end
def handle_service_error
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
end
def public_sharing_request?
params[:uuid].present?
end
def validate_bbox_params
required_params = %w[min_lon min_lat max_lon max_lat]
missing_params = required_params.select { |param| params[param].blank? }
return unless missing_params.any?
render json: {
error: "Missing required parameters: #{missing_params.join(', ')}"
}, status: :bad_request
end
end

View File

@@ -18,7 +18,7 @@ class Api::V1::SettingsController < ApiController
status: :ok status: :ok
else else
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages }, render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
status: :unprocessable_entity status: :unprocessable_content
end end
end end

View File

@@ -15,6 +15,6 @@ class Api::V1::SubscriptionsController < ApiController
render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
rescue ArgumentError => e rescue ArgumentError => e
ExceptionReporter.call(e) ExceptionReporter.call(e)
render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_content
end end
end end

View File

@@ -19,7 +19,7 @@ class Api::V1::VisitsController < ApiController
render json: Api::VisitSerializer.new(service.visit).call render json: Api::VisitSerializer.new(service.visit).call
else else
error_message = service.errors || 'Failed to create visit' error_message = service.errors || 'Failed to create visit'
render json: { error: error_message }, status: :unprocessable_entity render json: { error: error_message }, status: :unprocessable_content
end end
end end
@@ -34,7 +34,7 @@ class Api::V1::VisitsController < ApiController
# Validate that we have at least 2 visit IDs # Validate that we have at least 2 visit IDs
visit_ids = params[:visit_ids] visit_ids = params[:visit_ids]
if visit_ids.blank? || visit_ids.length < 2 if visit_ids.blank? || visit_ids.length < 2
return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_entity return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_content
end end
# Find all visits that belong to the current user # Find all visits that belong to the current user
@@ -52,7 +52,7 @@ class Api::V1::VisitsController < ApiController
if merged_visit&.persisted? if merged_visit&.persisted?
render json: Api::VisitSerializer.new(merged_visit).call, status: :ok render json: Api::VisitSerializer.new(merged_visit).call, status: :ok
else else
render json: { error: service.errors.join(', ') }, status: :unprocessable_entity render json: { error: service.errors.join(', ') }, status: :unprocessable_content
end end
end end
@@ -71,7 +71,7 @@ class Api::V1::VisitsController < ApiController
updated_count: result[:count] updated_count: result[:count]
}, status: :ok }, status: :ok
else else
render json: { error: service.errors.join(', ') }, status: :unprocessable_entity render json: { error: service.errors.join(', ') }, status: :unprocessable_content
end end
end end
@@ -84,7 +84,7 @@ class Api::V1::VisitsController < ApiController
render json: { render json: {
error: 'Failed to delete visit', error: 'Failed to delete visit',
errors: visit.errors.full_messages errors: visit.errors.full_messages
}, status: :unprocessable_entity }, status: :unprocessable_content
end end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render json: { error: 'Visit not found' }, status: :not_found render json: { error: 'Visit not found' }, status: :not_found

View File

@@ -3,6 +3,8 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Pundit::Authorization include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :unread_notifications, :set_self_hosted_status before_action :unread_notifications, :set_self_hosted_status
protected protected
@@ -16,13 +18,13 @@ class ApplicationController < ActionController::Base
def authenticate_admin! def authenticate_admin!
return if current_user&.admin? return if current_user&.admin?
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other user_not_authorized
end end
def authenticate_self_hosted! def authenticate_self_hosted!
return if DawarichSettings.self_hosted? return if DawarichSettings.self_hosted?
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other user_not_authorized
end end
def authenticate_active_user! def authenticate_active_user!
@@ -34,7 +36,7 @@ class ApplicationController < ActionController::Base
def authenticate_non_self_hosted! def authenticate_non_self_hosted!
return unless DawarichSettings.self_hosted? return unless DawarichSettings.self_hosted?
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other user_not_authorized
end end
private private
@@ -42,4 +44,10 @@ class ApplicationController < ActionController::Base
def set_self_hosted_status def set_self_hosted_status
@self_hosted = DawarichSettings.self_hosted? @self_hosted = DawarichSettings.self_hosted?
end end
def user_not_authorized
redirect_back_or_to root_path,
alert: 'You are not authorized to perform this action.',
status: :see_other
end
end end

View File

@@ -27,7 +27,7 @@ class ExportsController < ApplicationController
ExceptionReporter.call(e) ExceptionReporter.call(e)
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_content
end end
def destroy def destroy

View File

@@ -13,9 +13,9 @@ class ImportsController < ApplicationController
def index def index
@imports = policy_scope(Import) @imports = policy_scope(Import)
.select(:id, :name, :source, :created_at, :processed, :status) .select(:id, :name, :source, :created_at, :processed, :status)
.order(created_at: :desc) .order(created_at: :desc)
.page(params[:page]) .page(params[:page])
end end
def show; end def show; end
@@ -43,7 +43,7 @@ class ImportsController < ApplicationController
raw_files = Array(files_params).reject(&:blank?) raw_files = Array(files_params).reject(&:blank?)
if raw_files.empty? if raw_files.empty?
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity and return redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_content and return
end end
created_imports = [] created_imports = []
@@ -62,7 +62,7 @@ class ImportsController < ApplicationController
else else
redirect_to new_import_path, redirect_to new_import_path,
alert: 'No valid file references were found. Please upload files using the file selector.', alert: 'No valid file references were found. Please upload files using the file selector.',
status: :unprocessable_entity and return status: :unprocessable_content and return
end end
rescue StandardError => e rescue StandardError => e
if created_imports.present? if created_imports.present?
@@ -74,7 +74,7 @@ class ImportsController < ApplicationController
Rails.logger.error e.backtrace.join("\n") Rails.logger.error e.backtrace.join("\n")
ExceptionReporter.call(e) ExceptionReporter.call(e)
redirect_to new_import_path, alert: e.message, status: :unprocessable_entity redirect_to new_import_path, alert: e.message, status: :unprocessable_content
end end
def destroy def destroy
@@ -117,7 +117,7 @@ class ImportsController < ApplicationController
# Extract filename and extension # Extract filename and extension
basename = File.basename(original_name, File.extname(original_name)) basename = File.basename(original_name, File.extname(original_name))
extension = File.extname(original_name) extension = File.extname(original_name)
# Add current datetime # Add current datetime
timestamp = Time.current.strftime('%Y%m%d_%H%M%S') timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
"#{basename}_#{timestamp}#{extension}" "#{basename}_#{timestamp}#{extension}"
@@ -126,6 +126,6 @@ class ImportsController < ApplicationController
def validate_points_limit def validate_points_limit
limit_exceeded = PointsLimitExceeded.new(current_user).call limit_exceeded = PointsLimitExceeded.new(current_user).call
redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_entity if limit_exceeded redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_content if limit_exceeded
end end
end end

View File

@@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::UsersController < ApplicationController class Settings::UsersController < ApplicationController
before_action :authenticate_self_hosted!, except: [:export, :import] before_action :authenticate_self_hosted!, except: %i[export import]
before_action :authenticate_admin!, except: [:export, :import] before_action :authenticate_admin!, except: %i[export import]
before_action :authenticate_user! before_action :authenticate_user!
def index def index
@@ -19,7 +19,7 @@ class Settings::UsersController < ApplicationController
if @user.update(user_params) if @user.update(user_params)
redirect_to settings_users_url, notice: 'User was successfully updated.' redirect_to settings_users_url, notice: 'User was successfully updated.'
else else
redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_entity redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_content
end end
end end
@@ -33,7 +33,7 @@ class Settings::UsersController < ApplicationController
if @user.save if @user.save
redirect_to settings_users_url, notice: 'User was successfully created' redirect_to settings_users_url, notice: 'User was successfully created'
else else
redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_entity redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_content
end end
end end
@@ -43,7 +43,7 @@ class Settings::UsersController < ApplicationController
if @user.destroy if @user.destroy
redirect_to settings_url, notice: 'User was successfully deleted.' redirect_to settings_url, notice: 'User was successfully deleted.'
else else
redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_entity redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_content
end end
end end
@@ -90,8 +90,7 @@ class Settings::UsersController < ApplicationController
end end
def validate_archive_file(archive_file) def validate_archive_file(archive_file)
unless archive_file.content_type == 'application/zip' || unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) ||
archive_file.content_type == 'application/x-zip-compressed' ||
File.extname(archive_file.original_filename).downcase == '.zip' File.extname(archive_file.original_filename).downcase == '.zip'
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return

View File

@@ -0,0 +1,54 @@
# frozen_string_literal: true
class Shared::StatsController < ApplicationController
before_action :authenticate_user!, except: [:show]
before_action :authenticate_active_user!, only: [:update]
def show
@stat = Stat.find_by(sharing_uuid: params[:uuid])
unless @stat&.public_accessible?
return redirect_to root_path,
alert: 'Shared stats not found or no longer available'
end
@year = @stat.year
@month = @stat.month
@user = @stat.user
@is_public_view = true
@data_bounds = @stat.calculate_data_bounds
render 'stats/public_month'
end
def update
@year = params[:year].to_i
@month = params[:month].to_i
@stat = current_user.stats.find_by(year: @year, month: @month)
return head :not_found unless @stat
if params[:enabled] == '1'
@stat.enable_sharing!(expiration: params[:expiration] || 'permanent')
sharing_url = shared_stat_url(@stat.sharing_uuid)
render json: {
success: true,
sharing_url: sharing_url,
message: 'Sharing enabled successfully'
}
else
@stat.disable_sharing!
render json: {
success: true,
message: 'Sharing disabled successfully'
}
end
rescue StandardError
render json: {
success: false,
message: 'Failed to update sharing settings'
}, status: :unprocessable_content
end
end

View File

@@ -16,6 +16,14 @@ class StatsController < ApplicationController
@year_distances = { @year => Stat.year_distance(@year, current_user) } @year_distances = { @year => Stat.year_distance(@year, current_user) }
end end
def month
@year = params[:year].to_i
@month = params[:month].to_i
@stat = current_user.stats.find_by(year: @year, month: @month)
@previous_stat = current_user.stats.find_by(year: @year, month: @month - 1) if @month > 1
@average_distance_this_year = current_user.stats.where(year: @year).average(:distance) / 1000
end
def update def update
if params[:month] == 'all' if params[:month] == 'all'
(1..12).each do |month| (1..12).each do |month|

View File

@@ -16,9 +16,9 @@ class TripsController < ApplicationController
end end
@photo_sources = @trip.photo_sources @photo_sources = @trip.photo_sources
if @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank? return unless @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank?
Trips::CalculateAllJob.perform_later(@trip.id, current_user.safe_settings.distance_unit)
end Trips::CalculateAllJob.perform_later(@trip.id, current_user.safe_settings.distance_unit)
end end
def new def new
@@ -34,7 +34,7 @@ class TripsController < ApplicationController
if @trip.save if @trip.save
redirect_to @trip, notice: 'Trip was successfully created. Data is being calculated in the background.' redirect_to @trip, notice: 'Trip was successfully created. Data is being calculated in the background.'
else else
render :new, status: :unprocessable_entity render :new, status: :unprocessable_content
end end
end end
@@ -42,7 +42,7 @@ class TripsController < ApplicationController
if @trip.update(trip_params) if @trip.update(trip_params)
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
else else
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_content
end end
end end

View File

@@ -22,7 +22,7 @@ class VisitsController < ApplicationController
if @visit.update(visit_params) if @visit.update(visit_params)
redirect_back(fallback_location: visits_path(status: :suggested)) redirect_back(fallback_location: visits_path(status: :suggested))
else else
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_content
end end
end end

View File

@@ -17,26 +17,60 @@ module ApplicationHelper
{ start_at:, end_at: } { start_at:, end_at: }
end end
def timespan(month, year)
month = DateTime.new(year, month)
start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
{ start_at:, end_at: }
end
def header_colors def header_colors
%w[info success warning error accent secondary primary] %w[info success warning error accent secondary primary]
end end
def past?(year, month) def countries_and_cities_stat_for_year(year, stats)
DateTime.new(year, month).past? data = { countries: [], cities: [] }
stats.select { _1.year == year }.each do
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
end
data[:cities].flatten!.uniq!
data[:countries].flatten!.uniq!
grouped_by_country = {}
stats.select { _1.year == year }.each do |stat|
stat.toponyms.flatten.each do |toponym|
country = toponym['country']
next if country.blank?
grouped_by_country[country] ||= []
next if toponym['cities'].blank?
toponym['cities'].each do |city_data|
city = city_data['city']
grouped_by_country[country] << city if city.present?
end
end
end
grouped_by_country.transform_values!(&:uniq)
{
countries_count: data[:countries].count,
cities_count: data[:cities].count,
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
year: year,
modal_id: "countries_cities_modal_#{year}"
}
end end
def points_exist?(year, month, user) def countries_and_cities_stat_for_month(stat)
user.points.where( countries = stat.toponyms.count { _1['country'] }
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month cities = stat.toponyms.sum { _1['cities'].count }
).exists?
"#{countries} countries, #{cities} cities"
end
def year_distance_stat(year, user)
# Distance is now stored in meters, convert to user's preferred unit for display
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
end end
def new_version_available? def new_version_available?

View File

@@ -52,4 +52,182 @@ module StatsHelper
"#{countries} countries, #{cities} cities" "#{countries} countries, #{cities} cities"
end end
def distance_traveled(user, stat)
distance_unit = user.safe_settings.distance_unit
value =
if distance_unit == 'mi'
(stat.distance / 1609.34).round(2)
else
(stat.distance / 1000).round(2)
end
"#{number_with_delimiter(value)} #{distance_unit}"
end
def x_than_average_distance(stat, average_distance_this_year)
return '' if average_distance_this_year.zero?
difference = stat.distance / 1000 - average_distance_this_year
percentage = ((difference / average_distance_this_year) * 100).round
more_or_less = difference.positive? ? 'more' : 'less'
"#{percentage.abs}% #{more_or_less} than your average this year"
end
def x_than_previous_active_days(stat, previous_stat)
return '' unless previous_stat
previous_active_days = previous_stat.daily_distance.select { _1[1].positive? }.count
current_active_days = stat.daily_distance.select { _1[1].positive? }.count
difference = current_active_days - previous_active_days
return 'Same as previous month' if difference.zero?
more_or_less = difference.positive? ? 'more' : 'less'
days_word = pluralize(difference.abs, 'day')
"#{days_word} #{more_or_less} than previous month"
end
def active_days(stat)
total_days = stat.daily_distance.count
active_days = stat.daily_distance.select { _1[1].positive? }.count
"#{active_days}/#{total_days}"
end
def countries_visited(stat)
stat.toponyms.count { _1['country'] }
end
def x_than_prevopis_countries_visited(stat, previous_stat)
return '' unless previous_stat
previous_countries = previous_stat.toponyms.count { _1['country'] }
current_countries = stat.toponyms.count { _1['country'] }
difference = current_countries - previous_countries
return 'Same as previous month' if difference.zero?
more_or_less = difference.positive? ? 'more' : 'less'
countries_word = pluralize(difference.abs, 'country')
"#{countries_word} #{more_or_less} than previous month"
end
def peak_day(stat)
peak = stat.daily_distance.max_by { _1[1] }
return 'N/A' unless peak && peak[1].positive?
date = Date.new(stat.year, stat.month, peak[0])
distance_km = (peak[1] / 1000).round(2)
distance_unit = stat.user.safe_settings.distance_unit
distance_value =
if distance_unit == 'mi'
(peak[1] / 1609.34).round(2)
else
distance_km
end
text = "#{date.strftime('%B %d')} (#{distance_value} #{distance_unit})"
link_to text, map_url(start_at: date.beginning_of_day, end_at: date.end_of_day), class: 'underline'
end
def quietest_week(stat)
return 'N/A' if stat.daily_distance.empty?
# Create a hash with date as key and distance as value
distance_by_date = stat.daily_distance.to_h.transform_keys do |timestamp|
Time.at(timestamp).in_time_zone(stat.user.timezone || 'UTC').to_date
end
# Initialize variables to track the quietest week
quietest_start_date = nil
quietest_distance = Float::INFINITY
# Iterate through each day of the month to find the quietest week
start_date = distance_by_date.keys.min.beginning_of_month
end_date = distance_by_date.keys.max.end_of_month
(start_date..end_date).each_cons(7) do |week|
week_distance = week.sum { |date| distance_by_date[date] || 0 }
if week_distance < quietest_distance
quietest_distance = week_distance
quietest_start_date = week.first
end
end
return 'N/A' unless quietest_start_date
quietest_end_date = quietest_start_date + 6.days
start_str = quietest_start_date.strftime('%b %d')
end_str = quietest_end_date.strftime('%b %d')
"#{start_str} - #{end_str}"
end
def month_icon(stat)
case stat.month
when 1..2, 12 then 'snowflake'
when 3..5 then 'flower'
when 6..8 then 'tree-palm'
when 9..11 then 'leaf'
end
end
def month_color(stat)
case stat.month
when 1 then '#397bb5'
when 2 then '#5A4E9D'
when 3 then '#3B945E'
when 4 then '#7BC96F'
when 5 then '#FFD54F'
when 6 then '#FFA94D'
when 7 then '#FF6B6B'
when 8 then '#FF8C42'
when 9 then '#C97E4F'
when 10 then '#8B4513'
when 11 then '#5A2E2E'
when 12 then '#265d7d'
end
end
def month_gradient_classes(stat)
case stat.month
when 1 then 'bg-gradient-to-br from-blue-500 to-blue-800' # Winter blue
when 2 then 'bg-gradient-to-bl from-blue-600 to-purple-600' # Purple
when 3 then 'bg-gradient-to-tr from-green-400 to-green-700' # Spring green
when 4 then 'bg-gradient-to-tl from-green-500 to-green-700' # Light green
when 5 then 'bg-gradient-to-br from-yellow-400 to-yellow-600' # Spring yellow
when 6 then 'bg-gradient-to-bl from-orange-400 to-orange-600' # Summer orange
when 7 then 'bg-gradient-to-tr from-red-400 to-red-600' # Summer red
when 8 then 'bg-gradient-to-tl from-orange-600 to-red-400' # Orange-red
when 9 then 'bg-gradient-to-br from-orange-600 to-yellow-400' # Autumn orange
when 10 then 'bg-gradient-to-bl from-yellow-700 to-orange-700' # Autumn brown
when 11 then 'bg-gradient-to-tr from-red-800 to-red-900' # Dark red
when 12 then 'bg-gradient-to-tl from-blue-600 to-blue-700' # Winter dark blue
end
end
def month_bg_image(stat)
case stat.month
when 1 then image_url('backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg')
when 2 then image_url('backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg')
when 3 then image_url('backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg')
when 4 then image_url('backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg')
when 5 then image_url('backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg')
when 6 then image_url('backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg')
when 7 then image_url('backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg')
when 8 then image_url('backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg')
when 9 then image_url('backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg')
when 10 then image_url('backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg')
when 11 then image_url('backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg')
when 12 then image_url('backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg')
end
end
end end

View File

@@ -0,0 +1,309 @@
import L from "leaflet";
import { createHexagonGrid } from "../maps/hexagon_grid";
import { createAllMapLayers } from "../maps/layers";
import BaseController from "./base_controller";
export default class extends BaseController {
static targets = ["container"];
static values = {
year: Number,
month: Number,
uuid: String,
dataBounds: Object,
selfHosted: String
};
connect() {
super.connect();
console.log('🏁 Controller connected - loading overlay should be visible');
this.selfHosted = this.selfHostedValue || 'false';
this.initializeMap();
this.loadHexagons();
}
disconnect() {
if (this.hexagonGrid) {
this.hexagonGrid.destroy();
}
if (this.map) {
this.map.remove();
}
}
initializeMap() {
// Initialize map with interactive controls enabled
this.map = L.map(this.element, {
zoomControl: true,
scrollWheelZoom: true,
doubleClickZoom: true,
touchZoom: true,
dragging: true,
keyboard: false
});
// Add dynamic tile layer based on self-hosted setting
this.addMapLayers();
// Default view
this.map.setView([40.0, -100.0], 4);
}
addMapLayers() {
try {
// Use appropriate default layer based on self-hosted mode
const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
// If no layers were created, fall back to OSM
if (Object.keys(maps).length === 0) {
console.warn('No map layers available, falling back to OSM');
this.addFallbackOSMLayer();
}
} catch (error) {
console.error('Error creating map layers:', error);
console.log('Falling back to OSM tile layer');
this.addFallbackOSMLayer();
}
}
addFallbackOSMLayer() {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 15
}).addTo(this.map);
}
async loadHexagons() {
console.log('🎯 loadHexagons started - checking overlay state');
const initialLoadingElement = document.getElementById('map-loading');
console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default');
try {
// Use server-provided data bounds
const dataBounds = this.dataBoundsValue;
if (dataBounds && dataBounds.point_count > 0) {
// Set map view to data bounds BEFORE creating hexagon grid
this.map.fitBounds([
[dataBounds.min_lat, dataBounds.min_lng],
[dataBounds.max_lat, dataBounds.max_lng]
], { padding: [20, 20] });
// Wait for the map to finish fitting bounds
console.log('⏳ About to wait for map moveend - overlay should still be visible');
await new Promise(resolve => {
this.map.once('moveend', resolve);
// Fallback timeout in case moveend doesn't fire
setTimeout(resolve, 1000);
});
console.log('✅ Map fitBounds complete - checking overlay state');
const afterFitBoundsElement = document.getElementById('map-loading');
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
}
this.hexagonGrid = createHexagonGrid(this.map, {
apiEndpoint: '/api/v1/maps/hexagons',
style: {
fillColor: '#3388ff',
fillOpacity: 0.3,
color: '#3388ff',
weight: 1,
opacity: 0.7
},
debounceDelay: 300,
maxZoom: 15,
minZoom: 4
});
// Force hide immediately after creation to prevent auto-showing
this.hexagonGrid.hide();
// Disable all dynamic behavior by removing event listeners
this.map.off('moveend');
this.map.off('zoomend');
// Load hexagons only once on page load (static behavior)
// NOTE: Do NOT hide loading overlay here - let loadStaticHexagons() handle it
if (dataBounds && dataBounds.point_count > 0) {
await this.loadStaticHexagons();
} else {
console.warn('No data bounds or points available - not showing hexagons');
// Only hide loading indicator if no hexagons to load
const loadingElement = document.getElementById('map-loading');
if (loadingElement) {
loadingElement.style.display = 'none';
}
}
} catch (error) {
console.error('Error initializing hexagon grid:', error);
// Hide loading indicator on initialization error
const loadingElement = document.getElementById('map-loading');
if (loadingElement) {
loadingElement.style.display = 'none';
}
}
// Do NOT hide loading overlay here - let loadStaticHexagons() handle it completely
}
async loadStaticHexagons() {
console.log('🔄 Loading static hexagons for public sharing...');
// Ensure loading overlay is visible and disable map interaction
const loadingElement = document.getElementById('map-loading');
console.log('🔍 Loading element found:', !!loadingElement);
if (loadingElement) {
loadingElement.style.display = 'flex';
loadingElement.style.visibility = 'visible';
loadingElement.style.zIndex = '9999';
console.log('👁️ Loading overlay ENSURED visible - should be visible now');
}
// Disable map interaction during loading
this.map.dragging.disable();
this.map.touchZoom.disable();
this.map.doubleClickZoom.disable();
this.map.scrollWheelZoom.disable();
this.map.boxZoom.disable();
this.map.keyboard.disable();
if (this.map.tap) this.map.tap.disable();
// Add delay to ensure loading overlay is visible
await new Promise(resolve => setTimeout(resolve, 500));
try {
// Calculate date range for the month
const startDate = new Date(this.yearValue, this.monthValue - 1, 1);
const endDate = new Date(this.yearValue, this.monthValue, 0, 23, 59, 59);
// Use the full data bounds for hexagon request (not current map viewport)
const dataBounds = this.dataBoundsValue;
const params = new URLSearchParams({
min_lon: dataBounds.min_lng,
min_lat: dataBounds.min_lat,
max_lon: dataBounds.max_lng,
max_lat: dataBounds.max_lat,
hex_size: 1000, // Fixed 1km hexagons
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
uuid: this.uuidValue
});
const url = `/api/v1/maps/hexagons?${params}`;
console.log('📍 Fetching static hexagons from:', url);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('Hexagon API error:', response.status, response.statusText, errorText);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const geojsonData = await response.json();
console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`);
// Add hexagons directly to map as a static layer
if (geojsonData.features && geojsonData.features.length > 0) {
this.addStaticHexagonsToMap(geojsonData);
}
} catch (error) {
console.error('Failed to load static hexagons:', error);
} finally {
// Re-enable map interaction after loading (success or failure)
this.map.dragging.enable();
this.map.touchZoom.enable();
this.map.doubleClickZoom.enable();
this.map.scrollWheelZoom.enable();
this.map.boxZoom.enable();
this.map.keyboard.enable();
if (this.map.tap) this.map.tap.enable();
// Hide loading overlay
const loadingElement = document.getElementById('map-loading');
if (loadingElement) {
loadingElement.style.display = 'none';
console.log('🚫 Loading overlay hidden - hexagons are fully loaded');
}
}
}
addStaticHexagonsToMap(geojsonData) {
// Calculate max point count for color scaling
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
const staticHexagonLayer = L.geoJSON(geojsonData, {
style: (feature) => this.styleHexagon(),
onEachFeature: (feature, layer) => {
// Add popup with statistics
const props = feature.properties;
const popupContent = this.buildPopupContent(props);
layer.bindPopup(popupContent);
// Add hover effects
layer.on({
mouseover: (e) => this.onHexagonMouseOver(e),
mouseout: (e) => this.onHexagonMouseOut(e)
});
}
});
staticHexagonLayer.addTo(this.map);
}
styleHexagon() {
return {
fillColor: '#3388ff',
fillOpacity: 0.3,
color: '#3388ff',
weight: 1,
opacity: 0.3
};
}
buildPopupContent(props) {
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
return `
<div style="font-size: 12px; line-height: 1.4;">
<strong>Date Range:</strong><br>
<small>${startDate} - ${endDate}</small>
</div>
`;
}
onHexagonMouseOver(e) {
const layer = e.target;
// Store original style before changing
if (!layer._originalStyle) {
layer._originalStyle = {
fillOpacity: layer.options.fillOpacity,
weight: layer.options.weight,
opacity: layer.options.opacity
};
}
layer.setStyle({
fillOpacity: 0.8,
weight: 2,
opacity: 1.0
});
}
onHexagonMouseOut(e) {
const layer = e.target;
// Reset to stored original style
if (layer._originalStyle) {
layer.setStyle(layer._originalStyle);
}
}
}

View File

@@ -0,0 +1,131 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["enableToggle", "expirationSettings", "sharingLink", "loading", "expirationSelect"]
static values = { url: String }
connect() {
console.log("Sharing modal controller connected")
}
toggleSharing() {
const isEnabled = this.enableToggleTarget.checked
if (isEnabled) {
this.expirationSettingsTarget.classList.remove("hidden")
this.saveSettings() // Save immediately when enabling
} else {
this.expirationSettingsTarget.classList.add("hidden")
this.sharingLinkTarget.value = ""
this.saveSettings() // Save immediately when disabling
}
}
expirationChanged() {
// Save settings immediately when expiration changes
if (this.enableToggleTarget.checked) {
this.saveSettings()
}
}
saveSettings() {
// Show loading state
this.showLoadingState()
const formData = new FormData()
formData.append('enabled', this.enableToggleTarget.checked ? '1' : '0')
if (this.enableToggleTarget.checked && this.hasExpirationSelectTarget) {
formData.append('expiration', this.expirationSelectTarget.value || '1h')
} else if (this.enableToggleTarget.checked) {
formData.append('expiration', '1h')
}
// Use the URL value from the controller
const url = this.urlValue
fetch(url, {
method: 'PATCH',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(response => response.json())
.then(data => {
this.hideLoadingState()
if (data.success) {
// Update sharing link if provided
if (data.sharing_url) {
this.sharingLinkTarget.value = data.sharing_url
}
// Show a subtle notification for auto-save
this.showNotification("✓ Auto-saved", "success")
} else {
this.showNotification("Failed to save settings. Please try again.", "error")
}
})
.catch(error => {
console.error('Error:', error)
this.hideLoadingState()
this.showNotification("Failed to save settings. Please try again.", "error")
})
}
showLoadingState() {
if (this.hasLoadingTarget) {
this.loadingTarget.classList.remove("hidden")
}
}
hideLoadingState() {
if (this.hasLoadingTarget) {
this.loadingTarget.classList.add("hidden")
}
}
async copyLink() {
try {
await navigator.clipboard.writeText(this.sharingLinkTarget.value)
// Show temporary success feedback
const button = this.sharingLinkTarget.nextElementSibling
const originalText = button.innerHTML
button.innerHTML = "✅ Copied!"
button.classList.add("btn-success")
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove("btn-success")
}, 2000)
} catch (err) {
console.error("Failed to copy: ", err)
// Fallback: select the text
this.sharingLinkTarget.select()
this.sharingLinkTarget.setSelectionRange(0, 99999) // For mobile devices
}
}
showNotification(message, type) {
// Create a simple toast notification
const toast = document.createElement('div')
toast.className = `toast toast-top toast-end z-50`
toast.innerHTML = `
<div class="alert alert-${type === 'success' ? 'success' : 'error'}">
<span>${message}</span>
</div>
`
document.body.appendChild(toast)
// Remove after 3 seconds
setTimeout(() => {
toast.remove()
}, 3000)
}
}

View File

@@ -0,0 +1,287 @@
import L from "leaflet";
import "leaflet.heat";
import { createAllMapLayers } from "../maps/layers";
import BaseController from "./base_controller";
export default class extends BaseController {
static targets = ["map", "loading", "heatmapBtn", "pointsBtn"];
connect() {
super.connect();
console.log("StatPage controller connected");
// Get data attributes from the element (will be passed from the view)
this.year = parseInt(this.element.dataset.year || new Date().getFullYear());
this.month = parseInt(this.element.dataset.month || new Date().getMonth() + 1);
this.apiKey = this.element.dataset.apiKey;
this.selfHosted = this.element.dataset.selfHosted || this.selfHostedValue;
console.log(`Loading data for ${this.month}/${this.year} with API key: ${this.apiKey ? 'present' : 'missing'}`);
// Initialize map after a short delay to ensure container is ready
setTimeout(() => {
this.initializeMap();
}, 100);
}
disconnect() {
if (this.map) {
this.map.remove();
}
console.log("StatPage controller disconnected");
}
initializeMap() {
if (!this.mapTarget) {
console.error("Map target not found");
return;
}
try {
// Initialize Leaflet map
this.map = L.map(this.mapTarget, {
zoomControl: true,
scrollWheelZoom: true,
doubleClickZoom: true,
boxZoom: false,
keyboard: false,
dragging: true,
touchZoom: true
}).setView([52.520008, 13.404954], 10); // Default to Berlin
// Add dynamic tile layer based on self-hosted setting
this.addMapLayers();
// Add small scale control
L.control.scale({
position: 'bottomright',
maxWidth: 100,
imperial: true,
metric: true
}).addTo(this.map);
// Initialize layers
this.markersLayer = L.layerGroup(); // Don't add to map initially
this.heatmapLayer = null;
// Load data for this month
this.loadMonthData();
} catch (error) {
console.error("Error initializing map:", error);
this.showError("Failed to initialize map");
}
}
async loadMonthData() {
try {
// Show loading
this.showLoading(true);
// Calculate date range for the month
const startDate = `${this.year}-${this.month.toString().padStart(2, '0')}-01T00:00:00`;
const lastDay = new Date(this.year, this.month, 0).getDate();
const endDate = `${this.year}-${this.month.toString().padStart(2, '0')}-${lastDay}T23:59:59`;
console.log(`Fetching points from ${startDate} to ${endDate}`);
// Fetch points data for the month using Authorization header
const response = await fetch(`/api/v1/points?start_at=${encodeURIComponent(startDate)}&end_at=${encodeURIComponent(endDate)}&per_page=1000`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
if (!response.ok) {
console.error(`API request failed with status: ${response.status}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${Array.isArray(data) ? data.length : 0} points from API`);
if (Array.isArray(data) && data.length > 0) {
this.processPointsData(data);
} else {
console.log("No points data available for this month");
this.showNoData();
}
} catch (error) {
console.error("Error loading month data:", error);
this.showError("Failed to load location data");
// Don't fallback to mock data - show the error instead
} finally {
this.showLoading(false);
}
}
processPointsData(points) {
console.log(`Processing ${points.length} points for ${this.month}/${this.year}`);
// Clear existing markers
this.markersLayer.clearLayers();
// Convert points to markers (API returns latitude/longitude as strings)
const markers = points.map(point => {
const lat = parseFloat(point.latitude);
const lng = parseFloat(point.longitude);
return L.circleMarker([lat, lng], {
radius: 3,
fillColor: '#570df8',
color: '#570df8',
weight: 1,
opacity: 0.8,
fillOpacity: 0.6
});
});
// Add markers to layer (but don't add to map yet)
markers.forEach(marker => {
this.markersLayer.addLayer(marker);
});
// Prepare data for heatmap (convert strings to numbers)
this.heatmapData = points.map(point => [
parseFloat(point.latitude),
parseFloat(point.longitude),
0.5
]);
// Show heatmap by default
if (this.heatmapData.length > 0) {
this.heatmapLayer = L.heatLayer(this.heatmapData, {
radius: 25,
blur: 15,
maxZoom: 17,
max: 1.0
}).addTo(this.map);
// Set button states
this.heatmapBtnTarget.classList.add('btn-active');
this.pointsBtnTarget.classList.remove('btn-active');
}
// Fit map to show all points
if (points.length > 0) {
const group = new L.featureGroup(markers);
this.map.fitBounds(group.getBounds().pad(0.1));
}
console.log("Points processed successfully");
}
toggleHeatmap() {
if (!this.heatmapData || this.heatmapData.length === 0) {
console.warn("No heatmap data available");
return;
}
if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) {
// Remove heatmap
this.map.removeLayer(this.heatmapLayer);
this.heatmapLayer = null;
this.heatmapBtnTarget.classList.remove('btn-active');
// Show points
if (!this.map.hasLayer(this.markersLayer)) {
this.map.addLayer(this.markersLayer);
this.pointsBtnTarget.classList.add('btn-active');
}
} else {
// Add heatmap
this.heatmapLayer = L.heatLayer(this.heatmapData, {
radius: 25,
blur: 15,
maxZoom: 17,
max: 1.0
}).addTo(this.map);
this.heatmapBtnTarget.classList.add('btn-active');
// Hide points
if (this.map.hasLayer(this.markersLayer)) {
this.map.removeLayer(this.markersLayer);
this.pointsBtnTarget.classList.remove('btn-active');
}
}
}
togglePoints() {
if (this.map.hasLayer(this.markersLayer)) {
// Remove points
this.map.removeLayer(this.markersLayer);
this.pointsBtnTarget.classList.remove('btn-active');
} else {
// Add points
this.map.addLayer(this.markersLayer);
this.pointsBtnTarget.classList.add('btn-active');
// Remove heatmap if active
if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) {
this.map.removeLayer(this.heatmapLayer);
this.heatmapBtnTarget.classList.remove('btn-active');
}
}
}
showLoading(show) {
if (this.hasLoadingTarget) {
this.loadingTarget.style.display = show ? 'flex' : 'none';
}
}
showError(message) {
console.error(message);
if (this.hasLoadingTarget) {
this.loadingTarget.innerHTML = `
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>${message}</span>
</div>
`;
this.loadingTarget.style.display = 'flex';
}
}
showNoData() {
console.log("No data available for this month");
if (this.hasLoadingTarget) {
this.loadingTarget.innerHTML = `
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No location data available for ${new Date(this.year, this.month - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</span>
</div>
`;
this.loadingTarget.style.display = 'flex';
}
}
addMapLayers() {
try {
// Use appropriate default layer based on self-hosted mode
const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
// If no layers were created, fall back to OSM
if (Object.keys(maps).length === 0) {
console.warn('No map layers available, falling back to OSM');
this.addFallbackOSMLayer();
}
} catch (error) {
console.error('Error creating map layers:', error);
console.log('Falling back to OSM tile layer');
this.addFallbackOSMLayer();
}
}
addFallbackOSMLayer() {
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
}
}

View File

@@ -0,0 +1,363 @@
/**
* HexagonGrid - Manages hexagonal grid overlay on Leaflet maps
* Provides efficient loading and rendering of hexagon tiles based on viewport
*/
export class HexagonGrid {
constructor(map, options = {}) {
this.map = map;
this.options = {
apiEndpoint: '/api/v1/maps/hexagons',
style: {
fillColor: '#3388ff',
fillOpacity: 0.1,
color: '#3388ff',
weight: 1,
opacity: 0.5
},
debounceDelay: 300, // ms to wait before loading new hexagons
maxZoom: 18, // Don't show hexagons beyond this zoom level
minZoom: 8, // Don't show hexagons below this zoom level
...options
};
this.hexagonLayer = null;
this.loadingController = null; // For aborting requests
this.lastBounds = null;
this.isVisible = false;
this.init();
}
init() {
// Create the hexagon layer group
this.hexagonLayer = L.layerGroup();
// Bind map events
this.map.on('moveend', this.debounce(this.onMapMove.bind(this), this.options.debounceDelay));
this.map.on('zoomend', this.onZoomChange.bind(this));
// Initial load if within zoom range
if (this.shouldShowHexagons()) {
this.show();
}
}
/**
* Show the hexagon grid overlay
*/
show() {
if (!this.isVisible) {
this.isVisible = true;
if (this.shouldShowHexagons()) {
this.hexagonLayer.addTo(this.map);
this.loadHexagons();
}
}
}
/**
* Hide the hexagon grid overlay
*/
hide() {
if (this.isVisible) {
this.isVisible = false;
this.hexagonLayer.remove();
this.cancelPendingRequest();
}
}
/**
* Toggle visibility of hexagon grid
*/
toggle() {
if (this.isVisible) {
this.hide();
} else {
this.show();
}
}
/**
* Check if hexagons should be displayed at current zoom level
*/
shouldShowHexagons() {
const zoom = this.map.getZoom();
return zoom >= this.options.minZoom && zoom <= this.options.maxZoom;
}
/**
* Handle map move events
*/
onMapMove() {
if (!this.isVisible || !this.shouldShowHexagons()) {
return;
}
const currentBounds = this.map.getBounds();
// Only reload if bounds have changed significantly
if (this.boundsChanged(currentBounds)) {
this.loadHexagons();
}
}
/**
* Handle zoom change events
*/
onZoomChange() {
if (!this.isVisible) {
return;
}
if (this.shouldShowHexagons()) {
// Show hexagons and load for new zoom level
if (!this.map.hasLayer(this.hexagonLayer)) {
this.hexagonLayer.addTo(this.map);
}
this.loadHexagons();
} else {
// Hide hexagons when zoomed too far in/out
this.hexagonLayer.remove();
this.cancelPendingRequest();
}
}
/**
* Check if bounds have changed enough to warrant reloading
*/
boundsChanged(newBounds) {
if (!this.lastBounds) {
return true;
}
const threshold = 0.1; // 10% change threshold
const oldArea = this.getBoundsArea(this.lastBounds);
const newArea = this.getBoundsArea(newBounds);
const intersection = this.getBoundsIntersection(this.lastBounds, newBounds);
const intersectionRatio = intersection / Math.min(oldArea, newArea);
return intersectionRatio < (1 - threshold);
}
/**
* Calculate approximate area of bounds
*/
getBoundsArea(bounds) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
return (ne.lat - sw.lat) * (ne.lng - sw.lng);
}
/**
* Calculate intersection area between two bounds
*/
getBoundsIntersection(bounds1, bounds2) {
const sw1 = bounds1.getSouthWest();
const ne1 = bounds1.getNorthEast();
const sw2 = bounds2.getSouthWest();
const ne2 = bounds2.getNorthEast();
const left = Math.max(sw1.lng, sw2.lng);
const right = Math.min(ne1.lng, ne2.lng);
const bottom = Math.max(sw1.lat, sw2.lat);
const top = Math.min(ne1.lat, ne2.lat);
if (left < right && bottom < top) {
return (right - left) * (top - bottom);
}
return 0;
}
/**
* Load hexagons for current viewport
*/
async loadHexagons() {
console.log('❌ Using ORIGINAL loadHexagons method (should not happen for public sharing)');
// Cancel any pending request
this.cancelPendingRequest();
const bounds = this.map.getBounds();
this.lastBounds = bounds;
// Create new AbortController for this request
this.loadingController = new AbortController();
try {
// Get current date range from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at');
const endDate = urlParams.get('end_at');
// Get viewport dimensions
const mapContainer = this.map.getContainer();
const viewportWidth = mapContainer.offsetWidth;
const viewportHeight = mapContainer.offsetHeight;
const params = new URLSearchParams({
min_lon: bounds.getWest(),
min_lat: bounds.getSouth(),
max_lon: bounds.getEast(),
max_lat: bounds.getNorth(),
viewport_width: viewportWidth,
viewport_height: viewportHeight
});
// Add date parameters if they exist
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const response = await fetch(`${this.options.apiEndpoint}?${params}`, {
signal: this.loadingController.signal,
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const geojsonData = await response.json();
// Clear existing hexagons and add new ones
this.clearHexagons();
this.addHexagonsToMap(geojsonData);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to load hexagons:', error);
// Optionally show user-friendly error message
}
} finally {
this.loadingController = null;
}
}
/**
* Cancel pending hexagon loading request
*/
cancelPendingRequest() {
if (this.loadingController) {
this.loadingController.abort();
this.loadingController = null;
}
}
/**
* Clear existing hexagons from the map
*/
clearHexagons() {
this.hexagonLayer.clearLayers();
}
/**
* Add hexagons to the map from GeoJSON data
*/
addHexagonsToMap(geojsonData) {
if (!geojsonData.features || geojsonData.features.length === 0) {
return;
}
// Calculate max point count for color scaling
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
const geoJsonLayer = L.geoJSON(geojsonData, {
style: (feature) => this.styleHexagonByData(feature, maxPoints),
onEachFeature: (feature, layer) => {
// Add popup with statistics
const props = feature.properties;
const popupContent = this.buildPopupContent(props);
layer.bindPopup(popupContent);
}
});
geoJsonLayer.addTo(this.hexagonLayer);
}
/**
* Style hexagon based on point density and other data
*/
styleHexagonByData(feature, maxPoints) {
const props = feature.properties;
const pointCount = props.point_count || 0;
// Calculate opacity based on point density (0.2 to 0.8)
const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
let color = '#3388ff'
return {
fillColor: color,
fillOpacity: opacity,
color: color,
weight: 1,
opacity: opacity + 0.2
};
}
/**
* Build popup content with hexagon statistics
*/
buildPopupContent(props) {
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
return `
<div style="font-size: 12px; line-height: 1.4;">
<strong>Date Range:</strong><br>
<small>${startDate} - ${endDate}</small>
</div>
`;
}
/**
* Update hexagon style
*/
updateStyle(newStyle) {
this.options.style = { ...this.options.style, ...newStyle };
// Update existing hexagons
this.hexagonLayer.eachLayer((layer) => {
if (layer.setStyle) {
layer.setStyle(this.options.style);
}
});
}
/**
* Destroy the hexagon grid and clean up
*/
destroy() {
this.hide();
this.map.off('moveend');
this.map.off('zoomend');
this.hexagonLayer = null;
this.lastBounds = null;
}
/**
* Simple debounce utility
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
/**
* Create and return a new HexagonGrid instance
*/
export function createHexagonGrid(map, options = {}) {
return new HexagonGrid(map, options);
}
// Default export
export default HexagonGrid;

View File

@@ -1,29 +0,0 @@
# frozen_string_literal: true
# Lightweight cleanup job that runs weekly to catch any missed track generation.
#
# This provides a safety net while avoiding the overhead of daily bulk processing.
class Tracks::CleanupJob < ApplicationJob
queue_as :tracks
sidekiq_options retry: false
def perform(older_than: 1.day.ago)
users_with_old_untracked_points(older_than).find_each do |user|
# Process only the old untracked points
Tracks::Generator.new(
user,
end_at: older_than,
mode: :incremental
).call
end
end
private
def users_with_old_untracked_points(older_than)
User.active.joins(:points)
.where(points: { track_id: nil, timestamp: ..older_than.to_i })
.having('COUNT(points.id) >= 2') # Only users with enough points for tracks
.group(:id)
end
end

View File

@@ -1,13 +0,0 @@
# frozen_string_literal: true
class Tracks::CreateJob < ApplicationJob
queue_as :tracks
def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
user = User.find(user_id)
Tracks::Generator.new(user, start_at:, end_at:, mode:).call
rescue StandardError => e
ExceptionReporter.call(e, 'Failed to create tracks for user')
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
# Daily Track Generation Job
#
# Automatically processes new location points for all active/trial users on a regular schedule.
# This job runs periodically (recommended: every 2-4 hours) to generate tracks from newly
# received location data.
#
# Process:
# 1. Iterates through all active or trial users
# 2. For each user, finds the timestamp of their last track's end_at
# 3. Checks if there are new points since that timestamp
# 4. If new points exist, triggers parallel track generation using the existing system
# 5. Uses the parallel generator with 'daily' mode for optimal performance
#
# The job leverages the existing parallel track generation infrastructure,
# ensuring consistency with bulk operations while providing automatic daily processing.
class Tracks::DailyGenerationJob < ApplicationJob
queue_as :tracks
def perform
User.active_or_trial.find_each do |user|
next if user.points_count.zero?
process_user_daily_tracks(user)
rescue StandardError => e
ExceptionReporter.call(e, "Failed to process daily tracks for user #{user.id}")
end
end
private
def process_user_daily_tracks(user)
start_timestamp = start_timestamp(user)
return unless user.points.where('timestamp >= ?', start_timestamp).exists?
Tracks::ParallelGeneratorJob.perform_later(
user.id,
start_at: start_timestamp,
end_at: Time.current.to_i,
mode: 'daily'
)
end
def start_timestamp(user)
last_end = user.tracks.maximum(:end_at)&.to_i
return last_end + 1 if last_end
user.points.minimum(:timestamp) || 1.week.ago.to_i
end
end

View File

@@ -1,12 +0,0 @@
# frozen_string_literal: true
class Tracks::IncrementalCheckJob < ApplicationJob
queue_as :tracks
def perform(user_id, point_id)
user = User.find(user_id)
point = Point.find(point_id)
Tracks::IncrementalProcessor.new(user, point).call
end
end

View File

@@ -8,7 +8,7 @@ class Tracks::ParallelGeneratorJob < ApplicationJob
def perform(user_id, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day) def perform(user_id, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day)
user = User.find(user_id) user = User.find(user_id)
session = Tracks::ParallelGenerator.new( Tracks::ParallelGenerator.new(
user, user,
start_at: start_at, start_at: start_at,
end_at: end_at, end_at: end_at,

View File

@@ -17,7 +17,6 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
tracks_created = process_chunk tracks_created = process_chunk
update_session_progress(tracks_created) update_session_progress(tracks_created)
rescue StandardError => e rescue StandardError => e
ExceptionReporter.call(e, "Failed to process time chunk for user #{user_id}") ExceptionReporter.call(e, "Failed to process time chunk for user #{user_id}")
@@ -48,9 +47,7 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
# Create tracks from segments # Create tracks from segments
tracks_created = 0 tracks_created = 0
segments.each do |segment_points| segments.each do |segment_points|
if create_track_from_points_array(segment_points) tracks_created += 1 if create_track_from_points_array(segment_points)
tracks_created += 1
end
end end
tracks_created tracks_created

View File

@@ -14,6 +14,8 @@ class Users::MailerSendingJob < ApplicationJob
params = { user: user }.merge(options) params = { user: user }.merge(options)
UsersMailer.with(params).public_send(email_type).deliver_later UsersMailer.with(params).public_send(email_type).deliver_later
rescue ActiveRecord::RecordNotFound
Rails.logger.warn "User with ID #{user_id} not found. Skipping #{email_type} email."
end end
private private

View File

@@ -17,7 +17,8 @@ class Point < ApplicationRecord
index: true index: true
} }
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 }, suffix: true enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 },
suffix: true
enum :trigger, { enum :trigger, {
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3, unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
report_location_message_event: 4, manual_event: 5, timer_based_event: 6, report_location_message_event: 4, manual_event: 5, timer_based_event: 6,
@@ -33,7 +34,6 @@ class Point < ApplicationRecord
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? } after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? }
after_create :set_country after_create :set_country
after_create_commit :broadcast_coordinates after_create_commit :broadcast_coordinates
# after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
# after_commit :recalculate_track, on: :update, if: -> { track.present? } # after_commit :recalculate_track, on: :update, if: -> { track.present? }
def self.without_raw_data def self.without_raw_data
@@ -68,7 +68,7 @@ class Point < ApplicationRecord
def country_name def country_name
# TODO: Remove the country column in the future. # TODO: Remove the country column in the future.
read_attribute(:country_name) || self.country&.name || read_attribute(:country) || '' read_attribute(:country_name) || country&.name || self[:country] || ''
end end
private private
@@ -101,8 +101,4 @@ class Point < ApplicationRecord
def recalculate_track def recalculate_track
track.recalculate_path_and_distance! track.recalculate_path_and_distance!
end end
def trigger_incremental_track_generation
Tracks::IncrementalCheckJob.perform_later(user.id, id)
end
end end

View File

@@ -7,6 +7,8 @@ class Stat < ApplicationRecord
belongs_to :user belongs_to :user
before_create :generate_sharing_uuid
def distance_by_day def distance_by_day
monthly_points = points monthly_points = points
calculate_daily_distances(monthly_points) calculate_daily_distances(monthly_points)
@@ -30,8 +32,89 @@ class Stat < ApplicationRecord
.order(timestamp: :asc) .order(timestamp: :asc)
end end
def sharing_enabled?
sharing_settings['enabled'] == true
end
def sharing_expired?
return false unless sharing_settings['expiration']
return false if sharing_settings['expiration'] == 'permanent'
Time.current > sharing_settings['expires_at']
end
def public_accessible?
sharing_enabled? && !sharing_expired?
end
def generate_new_sharing_uuid!
update!(sharing_uuid: SecureRandom.uuid)
end
def enable_sharing!(expiration: '1h')
expires_at = case expiration
when '1h'
1.hour.from_now
when '12h'
12.hours.from_now
when '24h'
24.hours.from_now
end
update!(
sharing_settings: {
'enabled' => true,
'expiration' => expiration,
'expires_at' => expires_at&.iso8601
},
sharing_uuid: sharing_uuid || SecureRandom.uuid
)
end
def disable_sharing!
update!(
sharing_settings: {
'enabled' => false,
'expiration' => nil,
'expires_at' => nil
}
)
end
def calculate_data_bounds
start_date = Date.new(year, month, 1).beginning_of_day
end_date = start_date.end_of_month.end_of_day
points_relation = user.points.where(timestamp: start_date.to_i..end_date.to_i)
point_count = points_relation.count
return nil if point_count.zero?
bounds_result = ActiveRecord::Base.connection.exec_query(
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
MIN(longitude) as min_lng, MAX(longitude) as max_lng
FROM points
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3",
'data_bounds_query',
[user.id, start_date.to_i, end_date.to_i]
).first
{
min_lat: bounds_result['min_lat'].to_f,
max_lat: bounds_result['max_lat'].to_f,
min_lng: bounds_result['min_lng'].to_f,
max_lng: bounds_result['max_lng'].to_f,
point_count: point_count
}
end
private private
def generate_sharing_uuid
self.sharing_uuid ||= SecureRandom.uuid
end
def timespan def timespan
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
end end
@@ -40,8 +123,6 @@ class Stat < ApplicationRecord
Stats::DailyDistanceQuery.new(monthly_points, timespan, user_timezone).call Stats::DailyDistanceQuery.new(monthly_points, timespan, user_timezone).call
end end
private
def user_timezone def user_timezone
# Future: Once user.timezone column exists, uncomment the line below # Future: Once user.timezone column exists, uncomment the line below
# user.timezone.presence || Time.zone.name # user.timezone.presence || Time.zone.name

View File

@@ -23,12 +23,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
before_save :sanitize_input before_save :sanitize_input
validates :email, presence: true validates :email, presence: true
validates :reset_password_token, uniqueness: true, allow_nil: true validates :reset_password_token, uniqueness: true, allow_nil: true
attribute :admin, :boolean, default: false attribute :admin, :boolean, default: false
attribute :points_count, :integer, default: 0 attribute :points_count, :integer, default: 0
scope :active_or_trial, -> { where(status: %i[active trial]) }
enum :status, { inactive: 0, active: 1, trial: 2 } enum :status, { inactive: 0, active: 1, trial: 2 }
def safe_settings def safe_settings
@@ -127,6 +128,10 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
(points_count || 0).zero? && trial? (points_count || 0).zero? && trial?
end end
def timezone
Time.zone.name
end
private private
def create_api_key def create_api_key

View File

@@ -0,0 +1,104 @@
# frozen_string_literal: true
class HexagonQuery
# Maximum number of hexagons to return in a single request
MAX_HEXAGONS_PER_REQUEST = 5000
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil)
@min_lon = min_lon
@min_lat = min_lat
@max_lon = max_lon
@max_lat = max_lat
@hex_size = hex_size
@user_id = user_id
@start_date = start_date
@end_date = end_date
end
def call
ActiveRecord::Base.connection.execute(build_hexagon_sql)
end
private
def build_hexagon_sql
user_filter = user_id ? "user_id = #{user_id}" : '1=1'
date_filter = build_date_filter
<<~SQL
WITH bbox_geom AS (
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
),
bbox_utm AS (
SELECT
ST_Transform(geom, 3857) as geom_utm,
geom as geom_wgs84
FROM bbox_geom
),
user_points AS (
SELECT
lonlat::geometry as point_geom,
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
id,
timestamp
FROM points
WHERE #{user_filter}
#{date_filter}
AND ST_Intersects(
lonlat::geometry,
(SELECT geom FROM bbox_geom)
)
),
hex_grid AS (
SELECT
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm,
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i,
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
FROM bbox_utm
),
hexagons_with_points AS (
SELECT DISTINCT
hex_geom_utm,
hex_i,
hex_j
FROM hex_grid hg
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
),
hexagon_stats AS (
SELECT
hwp.hex_geom_utm,
hwp.hex_i,
hwp.hex_j,
COUNT(up.id) as point_count,
MIN(up.timestamp) as earliest_point,
MAX(up.timestamp) as latest_point
FROM hexagons_with_points hwp
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
)
SELECT
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
hex_i,
hex_j,
point_count,
earliest_point,
latest_point,
row_number() OVER (ORDER BY point_count DESC) as id
FROM hexagon_stats
ORDER BY point_count DESC
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
SQL
end
def build_date_filter
return '' unless start_date || end_date
conditions = []
conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date
conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
end
end

View File

@@ -11,7 +11,7 @@ class CountriesAndCities
def call def call
points points
.reject { |point| point.country_name.nil? || point.city.nil? } .reject { |point| point.country_name.nil? || point.city.nil? }
.group_by { |point| point.country_name } .group_by(&:country_name)
.transform_values { |country_points| process_country_points(country_points) } .transform_values { |country_points| process_country_points(country_points) }
.map { |country, cities| CountryData.new(country: country, cities: cities) } .map { |country, cities| CountryData.new(country: country, cities: cities) }
end end

View File

@@ -0,0 +1,153 @@
# frozen_string_literal: true
class Maps::HexagonGrid
include ActiveModel::Validations
# Constants for configuration
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
MAX_AREA_KM2 = 250_000 # 500km x 500km
# Validation error classes
class BoundingBoxTooLargeError < StandardError; end
class InvalidCoordinatesError < StandardError; end
class PostGISError < StandardError; end
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date, :viewport_width,
:viewport_height
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
validates :hex_size, numericality: { greater_than: 0 }
validate :validate_bbox_order
validate :validate_area_size
def initialize(params = {})
@min_lon = params[:min_lon].to_f
@min_lat = params[:min_lat].to_f
@max_lon = params[:max_lon].to_f
@max_lat = params[:max_lat].to_f
@hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
@viewport_width = params[:viewport_width]&.to_f
@viewport_height = params[:viewport_height]&.to_f
@user_id = params[:user_id]
@start_date = params[:start_date]
@end_date = params[:end_date]
end
def call
validate!
generate_hexagons
end
def area_km2
@area_km2 ||= calculate_area_km2
end
private
def calculate_area_km2
width = (max_lon - min_lon).abs
height = (max_lat - min_lat).abs
# Convert degrees to approximate kilometers
# 1 degree latitude ≈ 111 km
# 1 degree longitude ≈ 111 km * cos(latitude)
avg_lat = (min_lat + max_lat) / 2
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
height_km = height * 111
width_km * height_km
end
def validate_bbox_order
errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
end
def validate_area_size
return unless area_km2 > MAX_AREA_KM2
errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
end
def generate_hexagons
query = HexagonQuery.new(
min_lon:, min_lat:, max_lon:, max_lat:,
hex_size:, user_id:, start_date:, end_date:
)
result = query.call
format_hexagons(result)
rescue ActiveRecord::StatementInvalid => e
message = "Failed to generate hexagon grid: #{e.message}"
ExceptionReporter.call(e, message)
raise PostGISError, message
end
def format_hexagons(result)
total_points = 0
hexagons = result.map do |row|
point_count = row['point_count'].to_i
total_points += point_count
# Parse timestamps and format dates
earliest = row['earliest_point'] ? Time.zone.at(row['earliest_point'].to_f).iso8601 : nil
latest = row['latest_point'] ? Time.zone.at(row['latest_point'].to_f).iso8601 : nil
{
type: 'Feature',
id: row['id'],
geometry: JSON.parse(row['geojson']),
properties: {
hex_id: row['id'],
hex_i: row['hex_i'],
hex_j: row['hex_j'],
hex_size: hex_size,
point_count: point_count,
earliest_point: earliest,
latest_point: latest
}
}
end
{
type: 'FeatureCollection',
features: hexagons,
metadata: {
bbox: [min_lon, min_lat, max_lon, max_lat],
area_km2: area_km2.round(2),
hex_size_m: hex_size,
count: hexagons.count,
total_points: total_points,
user_id: user_id,
date_range: build_date_range_metadata
}
}
end
def build_date_range_metadata
return nil unless start_date || end_date
{ start_date:, end_date: }
end
def validate!
return if valid?
raise BoundingBoxTooLargeError, errors.full_messages.join(', ') if area_km2 > MAX_AREA_KM2
raise InvalidCoordinatesError, errors.full_messages.join(', ')
end
def viewport_valid?
viewport_width &&
viewport_height &&
viewport_width.positive? &&
viewport_height.positive?
end
end

View File

@@ -59,12 +59,13 @@ class Stats::CalculateMonth
end end
def toponyms def toponyms
toponym_points = user toponym_points =
.points user
.without_raw_data .points
.where(timestamp: start_timestamp..end_timestamp) .without_raw_data
.select(:city, :country_name) .where(timestamp: start_timestamp..end_timestamp)
.distinct .select(:city, :country_name)
.distinct
CountriesAndCities.new(toponym_points).call CountriesAndCities.new(toponym_points).call
end end

View File

@@ -1,215 +0,0 @@
# frozen_string_literal: true
# This service handles both bulk and incremental track generation using a unified
# approach with different modes:
#
# - :bulk - Regenerates all tracks from scratch (replaces existing)
# - :incremental - Processes untracked points up to a specified end time
# - :daily - Processes tracks on a daily basis
#
# Key features:
# - Deterministic results (same algorithm for all modes)
# - Simple incremental processing without buffering complexity
# - Configurable time and distance thresholds from user settings
# - Automatic track statistics calculation
# - Proper handling of edge cases (empty points, incomplete segments)
#
# Usage:
# # Bulk regeneration
# Tracks::Generator.new(user, mode: :bulk).call
#
# # Incremental processing
# Tracks::Generator.new(user, mode: :incremental).call
#
# # Daily processing
# Tracks::Generator.new(user, start_at: Date.current, mode: :daily).call
#
class Tracks::Generator
include Tracks::Segmentation
include Tracks::TrackBuilder
attr_reader :user, :start_at, :end_at, :mode
def initialize(user, start_at: nil, end_at: nil, mode: :bulk)
@user = user
@start_at = start_at
@end_at = end_at
@mode = mode.to_sym
end
def call
clean_existing_tracks if should_clean_tracks?
start_timestamp, end_timestamp = get_timestamp_range
segments = Track.get_segments_with_points(
user.id,
start_timestamp,
end_timestamp,
time_threshold_minutes,
distance_threshold_meters,
untracked_only: mode == :incremental
)
tracks_created = 0
segments.each do |segment|
track = create_track_from_segment(segment)
tracks_created += 1 if track
end
tracks_created
end
private
def should_clean_tracks?
case mode
when :bulk, :daily then true
else false
end
end
def load_points
case mode
when :bulk then load_bulk_points
when :incremental then load_incremental_points
when :daily then load_daily_points
else
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
end
end
def load_bulk_points
scope = user.points.order(:timestamp)
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
scope
end
def load_incremental_points
# For incremental mode, we process untracked points
# If end_at is specified, only process points up to that time
scope = user.points.where(track_id: nil).order(:timestamp)
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
scope
end
def load_daily_points
day_range = daily_time_range
user.points.where(timestamp: day_range).order(:timestamp)
end
def create_track_from_segment(segment_data)
points = segment_data[:points]
pre_calculated_distance = segment_data[:pre_calculated_distance]
return unless points.size >= 2
create_track_from_points(points, pre_calculated_distance)
end
def time_range_defined?
start_at.present? || end_at.present?
end
def time_range
return nil unless time_range_defined?
start_time = start_at&.to_i
end_time = end_at&.to_i
if start_time && end_time
Time.zone.at(start_time)..Time.zone.at(end_time)
elsif start_time
Time.zone.at(start_time)..
elsif end_time
..Time.zone.at(end_time)
end
end
def timestamp_range
return nil unless time_range_defined?
start_time = start_at&.to_i
end_time = end_at&.to_i
if start_time && end_time
start_time..end_time
elsif start_time
start_time..
elsif end_time
..end_time
end
end
def daily_time_range
day = start_at&.to_date || Date.current
day.beginning_of_day.to_i..day.end_of_day.to_i
end
def clean_existing_tracks
case mode
when :bulk then clean_bulk_tracks
when :daily then clean_daily_tracks
else
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
end
end
def clean_bulk_tracks
scope = user.tracks
scope = scope.where(start_at: time_range) if time_range_defined?
scope.destroy_all
end
def clean_daily_tracks
day_range = daily_time_range
range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
scope = user.tracks.where(start_at: range)
scope.destroy_all
end
def get_timestamp_range
case mode
when :bulk then bulk_timestamp_range
when :daily then daily_timestamp_range
when :incremental then incremental_timestamp_range
else
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
end
end
def bulk_timestamp_range
return [start_at.to_i, end_at.to_i] if start_at && end_at
first_point = user.points.order(:timestamp).first
last_point = user.points.order(:timestamp).last
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
end
def daily_timestamp_range
day = start_at&.to_date || Date.current
[day.beginning_of_day.to_i, day.end_of_day.to_i]
end
def incremental_timestamp_range
first_point = user.points.where(track_id: nil).order(:timestamp).first
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
[first_point&.timestamp || 0, end_timestamp]
end
def distance_threshold_meters
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
end
def time_threshold_minutes
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
end
end

View File

@@ -1,92 +0,0 @@
# frozen_string_literal: true
# This service analyzes new points as they're created and determines whether
# they should trigger incremental track generation based on time and distance
# thresholds defined in user settings.
#
# The key insight is that we should trigger track generation when there's a
# significant gap between the new point and the previous point, indicating
# the end of a journey and the start of a new one.
#
# Process:
# 1. Check if the new point should trigger processing (skip imported points)
# 2. Find the last point before the new point
# 3. Calculate time and distance differences
# 4. If thresholds are exceeded, trigger incremental generation
# 5. Set the end_at time to the previous point's timestamp for track finalization
#
# This ensures tracks are properly finalized when journeys end, not when they start.
#
# Usage:
# # In Point model after_create_commit callback
# Tracks::IncrementalProcessor.new(user, new_point).call
#
class Tracks::IncrementalProcessor
attr_reader :user, :new_point, :previous_point
def initialize(user, new_point)
@user = user
@new_point = new_point
@previous_point = find_previous_point
end
def call
return unless should_process?
start_at = find_start_time
end_at = find_end_time
Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
end
private
def should_process?
return false if new_point.import_id.present?
return true unless previous_point
exceeds_thresholds?(previous_point, new_point)
end
def find_previous_point
@previous_point ||=
user.points
.where('timestamp < ?', new_point.timestamp)
.order(:timestamp)
.last
end
def find_start_time
user.tracks.order(:end_at).last&.end_at
end
def find_end_time
previous_point ? Time.zone.at(previous_point.timestamp) : nil
end
def exceeds_thresholds?(previous_point, current_point)
time_gap = time_difference_minutes(previous_point, current_point)
distance_gap = distance_difference_meters(previous_point, current_point)
time_exceeded = time_gap >= time_threshold_minutes
distance_exceeded = distance_gap >= distance_threshold_meters
time_exceeded || distance_exceeded
end
def time_difference_minutes(point1, point2)
(point2.timestamp - point1.timestamp) / 60.0
end
def distance_difference_meters(point1, point2)
point1.distance_to(point2) * 1000
end
def time_threshold_minutes
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
end
def distance_threshold_meters
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
end
end

View File

@@ -17,8 +17,7 @@ class Tracks::ParallelGenerator
end end
def call def call
# Clean existing tracks if needed clean_bulk_tracks if mode == :bulk
clean_existing_tracks if should_clean_tracks?
# Generate time chunks # Generate time chunks
time_chunks = generate_time_chunks time_chunks = generate_time_chunks
@@ -40,13 +39,6 @@ class Tracks::ParallelGenerator
private private
def should_clean_tracks?
case mode
when :bulk, :daily then true
else false
end
end
def generate_time_chunks def generate_time_chunks
chunker = Tracks::TimeChunker.new( chunker = Tracks::TimeChunker.new(
user, user,
@@ -95,30 +87,18 @@ class Tracks::ParallelGenerator
) )
end end
def clean_existing_tracks
case mode
when :bulk then clean_bulk_tracks
when :daily then clean_daily_tracks
else
raise ArgumentError, "Unknown mode: #{mode}"
end
end
def clean_bulk_tracks def clean_bulk_tracks
if time_range_defined? if time_range_defined?
user.tracks.where(start_at: time_range).destroy_all user.tracks.where(
'(start_at, end_at) OVERLAPS (?, ?)',
start_at&.in_time_zone,
end_at&.in_time_zone
).destroy_all
else else
user.tracks.destroy_all user.tracks.destroy_all
end end
end end
def clean_daily_tracks
day_range = daily_time_range
range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
user.tracks.where(start_at: range).destroy_all
end
def time_range_defined? def time_range_defined?
start_at.present? || end_at.present? start_at.present? || end_at.present?
end end
@@ -162,8 +142,8 @@ class Tracks::ParallelGenerator
else else
# Convert seconds to readable format # Convert seconds to readable format
seconds = duration.to_i seconds = duration.to_i
if seconds >= 86400 # days if seconds >= 86_400 # days
days = seconds / 86400 days = seconds / 86_400
"#{days} day#{'s' if days != 1}" "#{days} day#{'s' if days != 1}"
elsif seconds >= 3600 # hours elsif seconds >= 3600 # hours
hours = seconds / 3600 hours = seconds / 3600

View File

@@ -21,8 +21,8 @@
# time_threshold_minutes methods. # time_threshold_minutes methods.
# #
# Used by: # Used by:
# - Tracks::Generator for splitting points during track generation # - Tracks::ParallelGenerator and related jobs for splitting points during parallel track generation
# - Tracks::CreateFromPoints for legacy compatibility # - Tracks::BoundaryDetector for cross-chunk track merging
# #
# Example usage: # Example usage:
# class MyTrackProcessor # class MyTrackProcessor

View File

@@ -27,6 +27,12 @@ class Tracks::SessionManager
} }
Rails.cache.write(cache_key, session_data, expires_in: DEFAULT_TTL) Rails.cache.write(cache_key, session_data, expires_in: DEFAULT_TTL)
# Initialize counters atomically using Redis SET
Rails.cache.redis.with do |redis|
redis.set(counter_key('completed_chunks'), 0, ex: DEFAULT_TTL.to_i)
redis.set(counter_key('tracks_created'), 0, ex: DEFAULT_TTL.to_i)
end
self self
end end
@@ -44,8 +50,10 @@ class Tracks::SessionManager
def get_session_data def get_session_data
data = Rails.cache.read(cache_key) data = Rails.cache.read(cache_key)
return nil unless data return nil unless data
# Rails.cache already deserializes the data, no need for JSON parsing # Include current counter values
data['completed_chunks'] = counter_value('completed_chunks')
data['tracks_created'] = counter_value('tracks_created')
data data
end end
@@ -65,20 +73,18 @@ class Tracks::SessionManager
# Increment completed chunks # Increment completed chunks
def increment_completed_chunks def increment_completed_chunks
session_data = get_session_data return false unless session_exists?
return false unless session_data
new_completed = session_data['completed_chunks'] + 1 atomic_increment(counter_key('completed_chunks'), 1)
update_session(completed_chunks: new_completed) true
end end
# Increment tracks created # Increment tracks created
def increment_tracks_created(count = 1) def increment_tracks_created(count = 1)
session_data = get_session_data return false unless session_exists?
return false unless session_data
new_count = session_data['tracks_created'] + count atomic_increment(counter_key('tracks_created'), count)
update_session(tracks_created: new_count) true
end end
# Mark session as completed # Mark session as completed
@@ -103,7 +109,8 @@ class Tracks::SessionManager
session_data = get_session_data session_data = get_session_data
return false unless session_data return false unless session_data
session_data['completed_chunks'] >= session_data['total_chunks'] completed_chunks = counter_value('completed_chunks')
completed_chunks >= session_data['total_chunks']
end end
# Get progress percentage # Get progress percentage
@@ -114,13 +121,16 @@ class Tracks::SessionManager
total = session_data['total_chunks'] total = session_data['total_chunks']
return 100 if total.zero? return 100 if total.zero?
completed = session_data['completed_chunks'] completed = counter_value('completed_chunks')
(completed.to_f / total * 100).round(2) (completed.to_f / total * 100).round(2)
end end
# Delete session # Delete session
def cleanup_session def cleanup_session
Rails.cache.delete(cache_key) Rails.cache.delete(cache_key)
Rails.cache.redis.with do |redis|
redis.del(counter_key('completed_chunks'), counter_key('tracks_created'))
end
end end
# Class methods for session management # Class methods for session management
@@ -149,4 +159,20 @@ class Tracks::SessionManager
def cache_key def cache_key
"#{CACHE_KEY_PREFIX}:user:#{user_id}:session:#{session_id}" "#{CACHE_KEY_PREFIX}:user:#{user_id}:session:#{session_id}"
end end
end
def counter_key(field)
"#{cache_key}:#{field}"
end
def counter_value(field)
Rails.cache.redis.with do |redis|
(redis.get(counter_key(field)) || 0).to_i
end
end
def atomic_increment(key, amount)
Rails.cache.redis.with do |redis|
redis.incrby(key, amount)
end
end
end

View File

@@ -25,7 +25,7 @@
# This ensures consistency when users change their distance unit preferences. # This ensures consistency when users change their distance unit preferences.
# #
# Used by: # Used by:
# - Tracks::Generator for creating tracks during generation # - Tracks::ParallelGenerator and related jobs for creating tracks during parallel generation
# - Any class that needs to convert point arrays to Track records # - Any class that needs to convert point arrays to Track records
# #
# Example usage: # Example usage:
@@ -60,7 +60,7 @@ module Tracks::TrackBuilder
) )
# TODO: Move trips attrs to columns with more precision and range # TODO: Move trips attrs to columns with more precision and range
track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max track.distance = [[pre_calculated_distance.round, 999_999].min, 0].max
track.duration = calculate_duration(points) track.duration = calculate_duration(points)
track.avg_speed = calculate_average_speed(track.distance, track.duration) track.avg_speed = calculate_average_speed(track.distance, track.duration)
@@ -103,7 +103,7 @@ module Tracks::TrackBuilder
speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h
# Cap the speed to prevent database precision overflow (max 999999.99) # Cap the speed to prevent database precision overflow (max 999999.99)
[speed_kmh, 999999.99].min [speed_kmh, 999_999.99].min
end end
def calculate_elevation_stats(points) def calculate_elevation_stats(points)
@@ -145,6 +145,6 @@ module Tracks::TrackBuilder
private private
def user def user
raise NotImplementedError, "Including class must implement user method" raise NotImplementedError, 'Including class must implement user method'
end end
end end

View File

@@ -60,11 +60,12 @@ class Users::ImportData::Stats
end end
def prepare_stat_attributes(stat_data) def prepare_stat_attributes(stat_data)
attributes = stat_data.except('created_at', 'updated_at') attributes = stat_data.except('created_at', 'updated_at', 'sharing_uuid')
attributes['user_id'] = user.id attributes['user_id'] = user.id
attributes['created_at'] = Time.current attributes['created_at'] = Time.current
attributes['updated_at'] = Time.current attributes['updated_at'] = Time.current
attributes['sharing_uuid'] = SecureRandom.uuid
attributes.symbolize_keys attributes.symbolize_keys
rescue StandardError => e rescue StandardError => e

View File

@@ -2,12 +2,10 @@
<p class='py-2'>Use this API key to authenticate your requests.</p> <p class='py-2'>Use this API key to authenticate your requests.</p>
<code><%= current_user.api_key %></code> <code><%= current_user.api_key %></code>
<% if ENV['QR_CODE_ENABLED'] == 'true' %> <p class='py-2'>
<p class='py-2'> Or you can scan it in your Dawarich iOS app:
Or you can scan it in your Dawarich iOS app: <%= api_key_qr_code(current_user) %>
<%= api_key_qr_code(current_user) %> </p>
</p>
<% end %>
<p class='py-2'> <p class='py-2'>
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p> <p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>

View File

@@ -87,19 +87,8 @@
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover" <div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
data-controller="notifications" data-controller="notifications"
data-notifications-user-id-value="<%= current_user.id %>"> data-notifications-user-id-value="<%= current_user.id %>">
<div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost'> <div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost p-2'>
<svg <%= icon 'bell' %>
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<% if @unread_notifications.present? %> <% if @unread_notifications.present? %>
<span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge"> <span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge">
<%= @unread_notifications.size %> <%= @unread_notifications.size %>
@@ -127,7 +116,9 @@
<span class="indicator-item badge badge-secondary badge-xs"></span> <span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %> <% end %>
<% if current_user.admin? %> <% if current_user.admin? %>
<span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">⭐️</span> <span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">
<%= icon 'star' %>
</span>
<% end %> <% end %>
</summary> </summary>
<ul class="p-2 bg-base-100 rounded-t-none z-10"> <ul class="p-2 bg-base-100 rounded-t-none z-10">

View File

@@ -0,0 +1,98 @@
<!-- Sharing Settings Modal -->
<dialog id="sharing_modal" class="modal">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
</form>
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
<%= icon 'link' %> Sharing Settings
</h3>
<div data-controller="sharing-modal"
data-sharing-modal-url-value="<%= sharing_stats_path(year: @year, month: @month) %>">
<!-- Enable/Disable Sharing Toggle -->
<div class="form-control mb-4">
<label class="label cursor-pointer">
<span class="label-text font-medium">Enable public access</span>
<input type="checkbox"
name="enabled"
<%= 'checked' if @stat.sharing_enabled? %>
class="toggle toggle-primary"
data-action="change->sharing-modal#toggleSharing"
data-sharing-modal-target="enableToggle" />
</label>
<div class="label">
<span class="label-text-alt text-gray-500">Allow others to view this monthly digest • Auto-saves on change</span>
</div>
</div>
<!-- Expiration Settings (shown when enabled) -->
<div data-sharing-modal-target="expirationSettings"
class="<%= 'hidden' unless @stat.sharing_enabled? %>">
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Link expiration</span>
</label>
<select name="expiration"
class="select select-bordered w-full"
data-sharing-modal-target="expirationSelect"
data-action="change->sharing-modal#expirationChanged">
<%= options_for_select([
['1 hour', '1h'],
['12 hours', '12h'],
['24 hours', '24h'],
['Permanent', 'permanent']
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
</select>
</div>
<!-- Sharing Link Display (shown when sharing is enabled) -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Sharing link</span>
</label>
<div class="join w-full">
<input type="text"
readonly
class="input input-bordered join-item flex-1"
data-sharing-modal-target="sharingLink"
value="<%= @stat.sharing_enabled? ? public_stat_url(@stat.sharing_uuid) : '' %>" />
<button type="button"
class="btn btn-outline join-item"
data-action="click->sharing-modal#copyLink">
<%= icon 'copy' %> Copy
</button>
</div>
<div class="label">
<span class="label-text-alt text-gray-500">Share this link to allow others to view your stats</span>
</div>
</div>
</div>
<!-- Privacy Notice (always visible) -->
<div class="alert alert-info mb-4">
<%= icon 'info' %>
<div>
<h3 class="font-bold">Privacy Protection</h3>
<div class="text-sm">
• Exact coordinates are hidden<br>
• Personal information is not included
</div>
</div>
</div>
<!-- Form Actions -->
<div class="modal-action">
<button type="button"
class="btn btn-primary"
onclick="sharing_modal.close()">
Done
</button>
</div>
</div>
</div>
</dialog>

View File

@@ -0,0 +1,320 @@
<!-- Monthly Digest Header -->
<div class="hero text-white rounded-lg shadow-lg mb-8"
style="background-image: url('<%= month_bg_image(stat) %>');">
<div class="hero-overlay bg-opacity-60"></div>
<div class="hero-content text-center relative w-full">
<div class="max-w-md mt-5">
<h1 class="text-4xl font-bold flex items-center justify-center gap-2">
<%= "#{icon month_icon(stat)} #{Date::MONTHNAMES[month]} #{year}".html_safe %>
</h1>
<p class="py-4">Monthly Digest</p>
<button class="btn btn-outline btn-sm text-neutral border-neutral hover:bg-white hover:text-primary"
onclick="sharing_modal.showModal()">
<%= icon 'share' %> Share
</button>
</div>
</div>
</div>
<div class="stats shadow shadow-lg mx-auto mb-8 w-full">
<div class="stat place-items-center text-center">
<div class="stat-title flex items-center justify-center gap-1">
<%= icon 'map-plus' %> Distance traveled
</div>
<div class="stat-value text-success">~<%= distance_traveled(current_user, stat) %></div>
<div class="stat-desc"><%= x_than_average_distance(stat, @average_distance_this_year) %></div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title flex items-center justify-center gap-1">
<%= icon 'calendar-check-2' %> Active days
</div>
<div class="stat-value text-secondary">
<%= active_days(stat) %>
</div>
<div class="stat-desc">
<%= x_than_previous_active_days(stat, previous_stat) %>
</div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title flex items-center justify-center gap-1">
<%= icon 'map-pin-plus' %> Countries visited
</div>
<div class="stat-value text-accent">
<%= countries_visited(stat) %>
</div>
<div class="stat-desc">
<%= x_than_prevopis_countries_visited(stat, previous_stat) %>
</div>
</div>
</div>
<!-- Map Summary - Full Width -->
<div class="card bg-base-100 shadow-xl mb-8"
data-controller="stat-page"
data-api-key="<%= current_user.api_key %>"
data-year="<%= year %>"
data-month="<%= month %>"
data-self-hosted="<%= @self_hosted %>">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<%= icon 'map' %>
Map Summary
</h2>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline btn-active" data-stat-page-target="heatmapBtn" data-action="click->stat-page#toggleHeatmap">
<%= icon 'flame' %> Heatmap
</button>
<button class="btn btn-sm btn-outline" data-stat-page-target="pointsBtn" data-action="click->stat-page#togglePoints">
<%= icon 'map-pin' %> Points
</button>
</div>
</div>
<!-- Leaflet Map Container -->
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
<div id="monthly-stats-map" data-stat-page-target="map" class="w-full h-full"></div>
<!-- Loading overlay -->
<div data-stat-page-target="loading" class="absolute inset-0 bg-base-200 flex items-center justify-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
</div>
<!-- Map Stats -->
<!--div class="stats grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div class="stat">
<div class="stat-title text-xs">Most visited</div>
<div class="stat-value text-sm">Downtown Area</div>
<div class="stat-desc text-xs">42 visits</div>
</div>
<div class="stat">
<div class="stat-title text-xs">Longest trip</div>
<div class="stat-value text-sm">156km</div>
<div class="stat-desc text-xs">Jan 15th</div>
</div>
<div class="stat">
<div class="stat-title text-xs">Total points</div>
<div class="stat-value text-sm">2,847</div>
<div class="stat-desc text-xs">tracked locations</div>
</div>
<div class="stat">
<div class="stat-title text-xs">Coverage area</div>
<div class="stat-value text-sm">45km²</div>
<div class="stat-desc text-xs">explored</div>
</div>
</div-->
</div>
</div>
<!-- Daily Activity Chart -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'activity' %> Daily Activity
</h2>
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
<%= column_chart(
stat.daily_distance.map { |day, distance_meters|
[day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
},
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Day',
ytitle: 'Distance',
colors: [
'#570df8', '#f000b8', '#ffea00',
'#00d084', '#3abff8', '#ff5724',
'#8e24aa', '#3949ab', '#00897b',
'#d81b60', '#5e35b1', '#039be5',
'#43a047', '#f4511e', '#6d4c41',
'#757575', '#546e7a', '#d32f2f'
],
library: {
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(0,0,0,0.1)' }
},
y: {
grid: { color: 'rgba(0,0,0,0.1)' }
}
}
}
) %>
</div>
<div class="text-sm opacity-70 text-center mt-2">
Peak day: <%= peak_day(stat) %> • Quietest week: <%= quietest_week(stat) %>
</div>
</div>
</div>
<!-- Top Destinations -->
<!--div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'trophy' %> Top Destinations
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
<div class="text-2xl">
<%= icon 'building' %>
</div>
<div class="flex-1">
<div class="font-bold">Downtown Office</div>
<div class="text-sm opacity-70">42 visits • 8.5 hrs</div>
</div>
<div class="badge badge-primary">1st</div>
</div>
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
<div class="text-2xl">
<%= icon 'house' %>
</div>
<div class="flex-1">
<div class="font-bold">Home Area</div>
<div class="text-sm opacity-70">31 visits • 156 hrs</div>
</div>
<div class="badge badge-secondary">2nd</div>
</div>
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
<div class="text-2xl">
<%= icon 'shopping-cart' %>
</div>
<div class="flex-1">
<div class="font-bold">Shopping District</div>
<div class="text-sm opacity-70">18 visits • 3.2 hrs</div>
</div>
<div class="badge badge-accent">3rd</div>
</div>
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
<div class="text-2xl">
<%= icon 'plane' %>
</div>
<div class="flex-1">
<div class="font-bold">Airport</div>
<div class="text-sm opacity-70">4 visits • 2.1 hrs</div>
</div>
<div class="badge badge-neutral">4th</div>
</div>
</div>
</div>
</div-->
<!-- Countries & Cities -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'globe' %> Countries & Cities
</h2>
<div class="space-y-4">
<% if stat.toponyms.present? %>
<% max_cities = stat.toponyms.map { |country| country['cities'].length }.max %>
<% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
<% stat.toponyms.each_with_index do |country, index| %>
<% cities_count = country['cities'].length %>
<% progress_value = max_cities > 0 ? (cities_count.to_f / max_cities * 100).round : 0 %>
<% color_class = progress_colors[index % progress_colors.length] %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold"><%= country['country'] %></span>
<span class="text-sm">
<%= pluralize(cities_count, 'city') %>
<% if progress_value > 0 %>
(<%= progress_value %>%)
<% end %>
</span>
</div>
<progress class="progress <%= color_class %> w-full" value="<%= progress_value %>" max="100"></progress>
</div>
<% end %>
<% else %>
<div class="text-center text-gray-500">
<p>No location data available for this month</p>
</div>
<% end %>
</div>
<div class="divider"></div>
<div class="flex flex-wrap gap-2">
<span class="text-sm font-medium">Cities visited:</span>
<% stat.toponyms.each do |country| %>
<% country['cities'].each do |city| %>
<div class="badge badge-outline"><%= city['city'] %></div>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Month Highlights -->
<!--div class="card bg-gradient-to-br from-primary to-secondary text-primary-content shadow-xl">
<div class="card-body">
<h2 class="card-title text-white">
<%= icon 'camera' %> Month Highlights
</h2>
<div class="stats grid grid-cols-2 md:grid-cols-4 gap-4 my-4">
<div class="stat">
<div class="stat-title text-white opacity-70">Photos taken</div>
<div class="stat-value text-white">127</div>
</div>
<div class="stat">
<div class="stat-title text-white opacity-70">Longest trip</div>
<div class="stat-value text-white">156km</div>
</div>
<div class="stat">
<div class="stat-title text-white opacity-70">New areas</div>
<div class="stat-value text-white">5</div>
</div>
<div class="stat">
<div class="stat-title text-white opacity-70">Travel time</div>
<div class="stat-value text-white">28.5h</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
<div class="flex items-center space-x-2">
<span class="text-white"><%= icon 'flame' %> Walking:</span>
<span class="font-bold text-white">45km</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-white"><%= icon 'bus' %> Public transport:</span>
<span class="font-bold text-white">12km</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-white"><%= icon 'car' %> Driving:</span>
<span class="font-bold text-white">1,190km</span>
</div>
</div>
<div class="alert bg-white bg-opacity-10 border-white border-opacity-20">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div class="text-white">
<h3 class="font-bold">
<%= icon 'lightbulb' %> Monthly Insights
</h3>
<p class="text-sm">You explored 3 new neighborhoods this month and visited your favorite coffee shop 15 times - that's every other day! ☕</p>
</div>
</div>
</div>
</div>
</div-->
<!-- Action Buttons -->
<div class="flex flex-wrap gap-4 mt-8 justify-center">
<a href="/stats/<%= year %>" class="btn btn-outline">← Back to <%= year %></a>
<button class="btn btn-outline" onclick="sharing_modal.showModal()">
<%= icon 'share' %> Share
</button>
</div>
<!-- Include Sharing Modal -->
<%= render 'shared/sharing_modal' %>

View File

@@ -1,30 +1,32 @@
<div class="border border-gray-500 rounded-md border-opacity-30 bg-gray-100 dark:bg-gray-800 p-3"> <%= link_to "#{stat.year}/#{stat.month}",
<div class="flex justify-between"> class: "group block p-6 bg-base-100 hover:bg-base-200/50 rounded-xl border border-base-300 hover:border-primary/40 hover:shadow-lg transition-all duration-200 hover:scale-[1.02]" do %>
<h4 class="stat-title text-left"><%= Date::MONTHNAMES[stat.month] %> <%= stat.year %></h4>
<div class="flex items-center space-x-2"> <!-- Month and Year -->
<%= link_to "Details", points_path(year: stat.year, month: stat.month), <div class="flex items-center justify-between mb-4">
class: "link link-primary" %> <h3 class="text-lg font-medium text-base-content group-hover:text-primary transition-colors flex items-center gap-2" style="color: <%= month_color(stat) %>;">
<%= "#{icon month_icon(stat)} #{Date::MONTHNAMES[stat.month]} #{stat.year}".html_safe %>
</h3>
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div> </div>
</div> </div>
<div class="flex"> <!-- Main Stats -->
<div class="stat-value"> <div class="space-y-3">
<p><%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %></p> <!-- Distance -->
<div>
<div class="text-2xl font-semibold text-base-content" style="color: <%= month_color(stat) %>;">
<%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %>
<span class="text-sm font-normal text-base-content/60 ml-1"><%= current_user.safe_settings.distance_unit %></span>
</div>
<div class="text-sm text-base-content/60">Total distance</div>
</div>
<!-- Location Summary -->
<div class="text-sm text-gray-600">
<%= countries_and_cities_stat_for_month(stat) %>
</div> </div>
</div> </div>
<% end %>
<div class="stat-desc">
<%= countries_and_cities_stat_for_month(stat) %>
</div>
<%= area_chart(
stat.daily_distance.map { |day, distance_meters|
[day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
},
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Day',
ytitle: 'Distance'
) %>
</div>

View File

@@ -10,7 +10,13 @@
height: '200px', height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}", suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days', xtitle: 'Days',
ytitle: 'Distance' ytitle: 'Distance',
colors: [
'#397bb5', '#5A4E9D', '#3B945E',
'#7BC96F', '#FFD54F', '#FFA94D',
'#FF6B6B', '#FF8C42', '#C97E4F',
'#8B4513', '#5A2E2E', '#265d7d'
]
) %> ) %>
</div> </div>
<div class="mt-5 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-4"> <div class="mt-5 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-4">

View File

@@ -39,9 +39,9 @@
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %> <%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %> <%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</div> </div>
<div class="gap-2"> <div class="flex items-center gap-2">
<span class='text-xs text-gray-500'>Last update: <%= human_date(stats.first.updated_at) %></span> <span class='text-xs text-gray-500'>Last update: <%= human_date(stats.first.updated_at) %></span>
<%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %> <%= link_to icon('refresh-ccw'), update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:text-primary' %>
</div> </div>
</h2> </h2>
<p> <p>
@@ -93,7 +93,13 @@
height: '200px', height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}", suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days', xtitle: 'Days',
ytitle: 'Distance' ytitle: 'Distance',
colors: [
'#397bb5', '#5A4E9D', '#3B945E',
'#7BC96F', '#FFD54F', '#FFA94D',
'#FF6B6B', '#FF8C42', '#C97E4F',
'#8B4513', '#5A2E2E', '#265d7d'
]
) %> ) %>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,5 @@
<% content_for :title, "#{Date::MONTHNAMES[@month]} #{@year} Monthly Digest" %>
<div class="w-full my-5">
<%= render partial: 'stats/month', locals: { year: @year, month: @month, stat: @stat, previous_stat: @previous_stat } %>
</div>

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shared Stats - <%= Date::MONTHNAMES[@month] %> <%= @year %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<% if @self_hosted %>
<!-- ProtomapsL for vector tiles -->
<script src="https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js"></script>
<% end %>
</head>
<body data-theme="dark">
<div class="min-h-screen bg-base-100 mx-auto">
<div class="container mx-auto px-4 py-8">
<!-- Monthly Digest Header -->
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background-image: url('<%= month_bg_image(@stat) %>');">
<div class="hero-overlay bg-opacity-60"></div>
<div class="hero-content text-center py-8">
<div class="max-w-lg">
<h1 class="text-4xl font-bold flex items-center justify-center gap-2">
<%= "#{icon month_icon(@stat)} #{Date::MONTHNAMES[@month]} #{@year}".html_safe %>
</h1>
<p class="pt-6 pb-2">Monthly Digest</p>
</div>
</div>
</div>
<div class="stats shadow mx-auto mb-8 w-full">
<div class="stat place-items-center text-center">
<div class="stat-title">Distance traveled</div>
<div class="stat-value"><%= distance_traveled(@user, @stat) %></div>
<div class="stat-desc">Total distance for this month</div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title">Active days</div>
<div class="stat-value text-secondary">
<%= active_days(@stat) %>
</div>
<div class="stat-desc text-secondary">
Days with tracked activity
</div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title">Countries visited</div>
<div class="stat-value">
<%= countries_visited(@stat) %>
</div>
<div class="stat-desc">
Different countries
</div>
</div>
</div>
<!-- Map Summary - Hexagon View -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body p-0">
<!-- Hexagon Map Container -->
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
<div id="public-monthly-stats-map" class="w-full h-full"
data-controller="public-stat-map"
data-public-stat-map-year-value="<%= @year %>"
data-public-stat-map-month-value="<%= @month %>"
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
<!-- Loading overlay -->
<div id="map-loading" class="absolute inset-0 bg-base-200 bg-opacity-80 flex items-center justify-center z-50">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-sm mt-2 text-base-content">Loading hexagons...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Daily Activity Chart -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'trending-up' %> Daily Activity
</h2>
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
<%= column_chart(
@stat.daily_distance.map { |day, distance_meters|
[day, Stat.convert_distance(distance_meters, 'km').round]
},
height: '200px',
suffix: " km",
xtitle: 'Day',
ytitle: 'Distance',
colors: [
'#570df8', '#f000b8', '#ffea00',
'#00d084', '#3abff8', '#ff5724',
'#8e24aa', '#3949ab', '#00897b',
'#d81b60', '#5e35b1', '#039be5',
'#43a047', '#f4511e', '#6d4c41',
'#757575', '#546e7a', '#d32f2f'
],
library: {
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(0,0,0,0.1)' }
},
y: {
grid: { color: 'rgba(0,0,0,0.1)' }
}
}
}
) %>
</div>
<div class="text-sm opacity-70 text-center mt-2">
Peak day: <%= peak_day(@stat) %> • Quietest week: <%= quietest_week(@stat) %>
</div>
</div>
</div>
<!-- Countries & Cities - General Info Only -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<% @stat.toponyms.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold"><%= country['country'] %></span>
<span class="text-sm"><%= country['cities'].length %> cities</span>
</div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 20) %>" max="100"></progress>
</div>
<% end %>
</div>
<div class="divider"></div>
<div class="flex flex-wrap gap-2">
<span class="text-sm font-medium">Cities visited:</span>
<% @stat.toponyms.each do |country| %>
<% country['cities'].first(5).each do |city| %>
<div class="badge badge-outline"><%= city['city'] %></div>
<% end %>
<% if country['cities'].length > 5 %>
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center py-8">
<div class="text-sm text-gray-500">
Powered by <a href="https://dawarich.app" class="link link-primary" target="_blank">Dawarich</a>, your personal memories mapper.
</div>
</div>
</div>
</div>
<!-- Map is now handled by the Stimulus controller -->
</body>
</html>

View File

@@ -302,7 +302,7 @@ Devise.setup do |config|
# When set to false, does not sign a user in automatically after their password is # When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password. # changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true # config.sign_in_after_change_password = true
config.responder.error_status = :unprocessable_entity config.responder.error_status = :unprocessable_content
config.responder.redirect_status = :see_other config.responder.redirect_status = :see_other
if Rails.env.production? && !DawarichSettings.self_hosted? if Rails.env.production? && !DawarichSettings.self_hosted?

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