Merge pull request #1757 from Freika/feature/stats-page

Feature/stats page
This commit is contained in:
Evgenii Burmakin 2025-09-13 14:00:45 +02:00 committed by GitHub
commit 6c81884f49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 3929 additions and 219 deletions

View file

@ -9,10 +9,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## 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
- 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

11
Gemfile
View file

@ -7,9 +7,9 @@ ruby File.read('.ruby-version').strip
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
gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'aws-sdk-core', '~> 3.215.1', 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 'chartkick'
gem 'data_migrate'
@ -19,37 +19,38 @@ gem 'gpx'
gem 'groupdate'
gem 'httparty'
gem 'importmap-rails'
gem 'jwt'
gem 'kaminari'
gem 'lograge'
gem 'oj'
gem 'parallel'
gem 'pg'
gem 'prometheus_exporter'
gem 'rqrcode', '~> 3.0'
gem 'puma'
gem 'pundit'
gem 'rails', '~> 8.0'
gem 'rails_icons'
gem 'redis'
gem 'rexml'
gem 'rgeo'
gem 'rgeo-activerecord'
gem 'rgeo-geojson'
gem 'rqrcode', '~> 3.0'
gem 'rswag-api'
gem 'rswag-ui'
gem 'rubyzip', '~> 2.4'
gem 'sentry-ruby'
gem 'sentry-rails'
gem 'stackprof'
gem 'sentry-ruby'
gem 'sidekiq'
gem 'sidekiq-cron'
gem 'sidekiq-limit_fetch'
gem 'sprockets-rails'
gem 'stackprof'
gem 'stimulus-rails'
gem 'strong_migrations'
gem 'tailwindcss-rails'
gem 'turbo-rails'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
gem 'jwt'
group :development, :test do
gem 'brakeman', require: false

View file

@ -334,6 +334,9 @@ GEM
rails-html-sanitizer (1.6.2)
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)
rails_icons (1.4.0)
nokogiri (~> 1.16, >= 1.16.4)
rails (> 6.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
@ -554,6 +557,7 @@ DEPENDENCIES
puma
pundit
rails (~> 8.0)
rails_icons
redis
rexml
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 {
content: '🔵';
content: '';
font-size: 18px;
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
render json: @area, status: :created
else
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
end
end
@ -23,7 +23,7 @@ class Api::V1::AreasController < ApiController
if @area.update(area_params)
render json: @area, status: :ok
else
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
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
else
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
status: :unprocessable_entity
status: :unprocessable_content
end
end

View file

@ -15,6 +15,6 @@ class Api::V1::SubscriptionsController < ApiController
render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
rescue ArgumentError => 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

View file

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

View file

@ -3,6 +3,8 @@
class ApplicationController < ActionController::Base
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :unread_notifications, :set_self_hosted_status
protected
@ -16,13 +18,13 @@ class ApplicationController < ActionController::Base
def authenticate_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
def authenticate_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
def authenticate_active_user!
@ -34,7 +36,7 @@ class ApplicationController < ActionController::Base
def authenticate_non_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
private
@ -42,4 +44,10 @@ class ApplicationController < ActionController::Base
def set_self_hosted_status
@self_hosted = DawarichSettings.self_hosted?
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

View file

@ -27,7 +27,7 @@ class ExportsController < ApplicationController
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
def destroy

View file

@ -13,9 +13,9 @@ class ImportsController < ApplicationController
def index
@imports = policy_scope(Import)
.select(:id, :name, :source, :created_at, :processed, :status)
.order(created_at: :desc)
.page(params[:page])
.select(:id, :name, :source, :created_at, :processed, :status)
.order(created_at: :desc)
.page(params[:page])
end
def show; end
@ -43,7 +43,7 @@ class ImportsController < ApplicationController
raw_files = Array(files_params).reject(&:blank?)
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
created_imports = []
@ -62,7 +62,7 @@ class ImportsController < ApplicationController
else
redirect_to new_import_path,
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
rescue StandardError => e
if created_imports.present?
@ -74,7 +74,7 @@ class ImportsController < ApplicationController
Rails.logger.error e.backtrace.join("\n")
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
def destroy
@ -117,7 +117,7 @@ class ImportsController < ApplicationController
# Extract filename and extension
basename = File.basename(original_name, File.extname(original_name))
extension = File.extname(original_name)
# Add current datetime
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
"#{basename}_#{timestamp}#{extension}"
@ -126,6 +126,6 @@ class ImportsController < ApplicationController
def validate_points_limit
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

View file

@ -1,8 +1,8 @@
# frozen_string_literal: true
class Settings::UsersController < ApplicationController
before_action :authenticate_self_hosted!, except: [:export, :import]
before_action :authenticate_admin!, except: [:export, :import]
before_action :authenticate_self_hosted!, except: %i[export import]
before_action :authenticate_admin!, except: %i[export import]
before_action :authenticate_user!
def index
@ -19,7 +19,7 @@ class Settings::UsersController < ApplicationController
if @user.update(user_params)
redirect_to settings_users_url, notice: 'User was successfully updated.'
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
@ -33,7 +33,7 @@ class Settings::UsersController < ApplicationController
if @user.save
redirect_to settings_users_url, notice: 'User was successfully created'
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
@ -43,7 +43,7 @@ class Settings::UsersController < ApplicationController
if @user.destroy
redirect_to settings_url, notice: 'User was successfully deleted.'
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
@ -90,8 +90,7 @@ class Settings::UsersController < ApplicationController
end
def validate_archive_file(archive_file)
unless archive_file.content_type == 'application/zip' ||
archive_file.content_type == 'application/x-zip-compressed' ||
unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) ||
File.extname(archive_file.original_filename).downcase == '.zip'
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) }
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
if params[:month] == 'all'
(1..12).each do |month|

View file

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

View file

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

View file

@ -17,14 +17,6 @@ module ApplicationHelper
{ start_at:, end_at: }
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
%w[info success warning error accent secondary primary]
end
@ -81,16 +73,6 @@ module ApplicationHelper
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
end
def past?(year, month)
DateTime.new(year, month).past?
end
def points_exist?(year, month, user)
user.points.where(
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
).exists?
end
def new_version_available?
CheckAppVersion.new.call
end

181
app/helpers/stats_helper.rb Normal file
View file

@ -0,0 +1,181 @@
# frozen_string_literal: true
module StatsHelper
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

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

@ -14,6 +14,8 @@ class Users::MailerSendingJob < ApplicationJob
params = { user: user }.merge(options)
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
private

View file

@ -7,6 +7,8 @@ class Stat < ApplicationRecord
belongs_to :user
before_create :generate_sharing_uuid
def distance_by_day
monthly_points = points
calculate_daily_distances(monthly_points)
@ -30,8 +32,89 @@ class Stat < ApplicationRecord
.order(timestamp: :asc)
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
def generate_sharing_uuid
self.sharing_uuid ||= SecureRandom.uuid
end
def timespan
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
end
@ -40,8 +123,6 @@ class Stat < ApplicationRecord
Stats::DailyDistanceQuery.new(monthly_points, timespan, user_timezone).call
end
private
def user_timezone
# Future: Once user.timezone column exists, uncomment the line below
# user.timezone.presence || Time.zone.name

View file

@ -122,6 +122,10 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
(points_count || 0).zero? && trial?
end
def timezone
Time.zone.name
end
private
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
points
.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) }
.map { |country, cities| CountryData.new(country: country, cities: cities) }
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
def toponyms
toponym_points = user
.points
.without_raw_data
.where(timestamp: start_timestamp..end_timestamp)
.select(:city, :country_name)
.distinct
toponym_points =
user
.points
.without_raw_data
.where(timestamp: start_timestamp..end_timestamp)
.select(:city, :country_name)
.distinct
CountriesAndCities.new(toponym_points).call
end

View file

@ -60,11 +60,12 @@ class Users::ImportData::Stats
end
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['created_at'] = Time.current
attributes['updated_at'] = Time.current
attributes['sharing_uuid'] = SecureRandom.uuid
attributes.symbolize_keys
rescue StandardError => e

View file

@ -2,12 +2,10 @@
<p class='py-2'>Use this API key to authenticate your requests.</p>
<code><%= current_user.api_key %></code>
<% if ENV['QR_CODE_ENABLED'] == 'true' %>
<p class='py-2'>
Or you can scan it in your Dawarich iOS app:
<%= api_key_qr_code(current_user) %>
</p>
<% end %>
<p class='py-2'>
Or you can scan it in your Dawarich iOS app:
<%= api_key_qr_code(current_user) %>
</p>
<p class='py-2'>
<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"
data-controller="notifications"
data-notifications-user-id-value="<%= current_user.id %>">
<div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost'>
<svg
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>
<div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost p-2'>
<%= icon 'bell' %>
<% if @unread_notifications.present? %>
<span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge">
<%= @unread_notifications.size %>
@ -127,7 +116,9 @@
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>
<% 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 %>
</summary>
<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">
<div class="flex justify-between">
<h4 class="stat-title text-left"><%= Date::MONTHNAMES[stat.month] %> <%= stat.year %></h4>
<%= link_to "#{stat.year}/#{stat.month}",
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 %>
<div class="flex items-center space-x-2">
<%= link_to "Details", points_path(year: stat.year, month: stat.month),
class: "link link-primary" %>
<!-- Month and Year -->
<div class="flex items-center justify-between mb-4">
<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 class="flex">
<div class="stat-value">
<p><%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %></p>
<!-- Main Stats -->
<div class="space-y-3">
<!-- 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 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>
<% end %>

View file

@ -10,7 +10,13 @@
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
ytitle: 'Distance'
ytitle: 'Distance',
colors: [
'#397bb5', '#5A4E9D', '#3B945E',
'#7BC96F', '#FFD54F', '#FFA94D',
'#FF6B6B', '#FF8C42', '#C97E4F',
'#8B4513', '#5A2E2E', '#265d7d'
]
) %>
</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">

View file

@ -34,9 +34,9 @@
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</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>
<%= 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>
</h2>
<p>
@ -90,7 +90,13 @@
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
ytitle: 'Distance'
ytitle: 'Distance',
colors: [
'#397bb5', '#5A4E9D', '#3B945E',
'#7BC96F', '#FFD54F', '#FFA94D',
'#FF6B6B', '#FF8C42', '#C97E4F',
'#8B4513', '#5A2E2E', '#265d7d'
]
) %>
</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
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# 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
if Rails.env.production? && !DawarichSettings.self_hosted?

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
RailsIcons.configure do |config|
config.default_library = 'lucide'
# config.default_variant = "" # Set a default variant for all libraries
# Override Lucide defaults
# config.libraries.lucide.default_variant = "" # Set a default variant for Lucide
# config.libraries.lucide.exclude_variants = [] # Exclude specific variants
# config.libraries.lucide.outline.default.css = "size-6"
# config.libraries.lucide.outline.default.stroke_width = "1.5"
# config.libraries.lucide.outline.default.data = {}
end

View file

@ -70,10 +70,21 @@ Rails.application.routes.draw do
end
end
get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ }
get 'stats/:year/:month', to: 'stats#month', constraints: { year: /\d{4}/, month: /\d{1,2}/ }
put 'stats/:year/:month/update',
to: 'stats#update',
as: :update_year_month_stats,
constraints: { year: /\d{4}/, month: /\d{1,2}|all/ }
# Public shared stats routes
get 'shared/stats/:uuid', to: 'shared/stats#show', as: :shared_stat
# Backward compatibility
get 'shared/stats/:uuid', to: 'shared/stats#show', as: :public_stat
# Sharing management endpoint (requires auth)
patch 'stats/:year/:month/sharing',
to: 'shared/stats#update',
as: :sharing_stats,
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
root to: 'home#index'
@ -140,6 +151,11 @@ Rails.application.routes.draw do
namespace :maps do
resources :tile_usage, only: [:create]
resources :hexagons, only: [:index] do
collection do
get :bounds
end
end
end
post 'subscriptions/callback', to: 'subscriptions#callback'

View file

@ -2,9 +2,7 @@
class RecalculateTripsDistance < ActiveRecord::Migration[8.0]
def up
Trip.find_each do |trip|
trip.enqueue_calculation_jobs
end
Trip.find_each(&:enqueue_calculation_jobs)
end
def down

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddSharingFieldsToStats < ActiveRecord::Migration[8.0]
def change
add_column :stats, :sharing_settings, :jsonb, default: {}
add_column :stats, :sharing_uuid, :uuid
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddIndexToStatsShareUuid < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :stats, :sharing_uuid, unique: true, algorithm: :concurrently
end
end

6
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -205,6 +205,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["track_id"], name: "index_points_on_track_id"
t.index ["trigger"], name: "index_points_on_trigger"
t.index ["user_id", "country_name"], name: "idx_points_user_country_name"
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
t.index ["user_id"], name: "index_points_on_user_id"
t.index ["visit_id"], name: "index_points_on_visit_id"
@ -219,8 +220,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.jsonb "daily_distance", default: {}
t.jsonb "sharing_settings", default: {}
t.uuid "sharing_uuid"
t.index ["distance"], name: "index_stats_on_distance"
t.index ["month"], name: "index_stats_on_month"
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
t.index ["user_id"], name: "index_stats_on_user_id"
t.index ["year"], name: "index_stats_on_year"
end

View file

@ -6,6 +6,8 @@ FactoryBot.define do
month { 1 }
distance { 1000 } # 1 km
user
sharing_settings { {} }
sharing_uuid { SecureRandom.uuid }
toponyms do
[
{
@ -16,5 +18,31 @@ FactoryBot.define do
}, { 'cities' => [], 'country' => nil }
]
end
trait :with_sharing_enabled do
after(:create) do |stat, _evaluator|
stat.enable_sharing!(expiration: 'permanent')
end
end
trait :with_sharing_disabled do
sharing_settings do
{
'enabled' => false,
'expiration' => nil,
'expires_at' => nil
}
end
end
trait :with_sharing_expired do
sharing_settings do
{
'enabled' => true,
'expiration' => '1h',
'expires_at' => 1.hour.ago.iso8601
}
end
end
end
end

View file

@ -111,9 +111,9 @@ RSpec.describe Users::MailerSendingJob, type: :job do
it 'raises ActiveRecord::RecordNotFound' do
user.destroy
expect {
expect do
described_class.perform_now(user.id, 'welcome')
}.to raise_error(ActiveRecord::RecordNotFound)
end.not_to raise_error
end
end
end

View file

@ -102,5 +102,165 @@ RSpec.describe Stat, type: :model do
expect(subject).to eq(points)
end
end
describe '#calculate_data_bounds' do
let(:stat) { create(:stat, year: 2024, month: 6, user:) }
let(:user) { create(:user) }
context 'when stat has points' do
before do
# Create test points within the month (June 2024)
create(:point,
user:,
latitude: 40.6,
longitude: -74.1,
timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
create(:point,
user:,
latitude: 40.8,
longitude: -73.9,
timestamp: Time.new(2024, 6, 15, 15, 0).to_i)
create(:point,
user:,
latitude: 40.7,
longitude: -74.0,
timestamp: Time.new(2024, 6, 30, 18, 0).to_i)
# Points outside the month (should be ignored)
create(:point,
user:,
latitude: 41.0,
longitude: -75.0,
timestamp: Time.new(2024, 5, 31, 23, 59).to_i) # May
create(:point,
user:,
latitude: 39.0,
longitude: -72.0,
timestamp: Time.new(2024, 7, 1, 0, 1).to_i) # July
end
it 'returns correct bounding box for points within the month' do
result = stat.calculate_data_bounds
expect(result).to be_a(Hash)
expect(result[:min_lat]).to eq(40.6)
expect(result[:max_lat]).to eq(40.8)
expect(result[:min_lng]).to eq(-74.1)
expect(result[:max_lng]).to eq(-73.9)
expect(result[:point_count]).to eq(3)
end
context 'with points from different users' do
let(:other_user) { create(:user) }
before do
# Add points from a different user (should be ignored)
create(:point,
user: other_user,
latitude: 50.0,
longitude: -80.0,
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
end
it 'only includes points from the stat user' do
result = stat.calculate_data_bounds
expect(result[:min_lat]).to eq(40.6)
expect(result[:max_lat]).to eq(40.8)
expect(result[:min_lng]).to eq(-74.1)
expect(result[:max_lng]).to eq(-73.9)
expect(result[:point_count]).to eq(3) # Still only 3 points from the stat user
end
end
context 'with single point' do
let(:single_point_user) { create(:user) }
let(:single_point_stat) { create(:stat, year: 2024, month: 7, user: single_point_user) }
before do
create(:point,
user: single_point_user,
latitude: 45.5,
longitude: -122.65,
timestamp: Time.new(2024, 7, 15, 14, 30).to_i)
end
it 'returns bounds with same min and max values' do
result = single_point_stat.calculate_data_bounds
expect(result[:min_lat]).to eq(45.5)
expect(result[:max_lat]).to eq(45.5)
expect(result[:min_lng]).to eq(-122.65)
expect(result[:max_lng]).to eq(-122.65)
expect(result[:point_count]).to eq(1)
end
end
context 'with edge case coordinates' do
let(:edge_user) { create(:user) }
let(:edge_stat) { create(:stat, year: 2024, month: 8, user: edge_user) }
before do
# Test with extreme coordinate values
create(:point,
user: edge_user,
latitude: -90.0, # South Pole
longitude: -180.0, # Date Line West
timestamp: Time.new(2024, 8, 1, 0, 0).to_i)
create(:point,
user: edge_user,
latitude: 90.0, # North Pole
longitude: 180.0, # Date Line East
timestamp: Time.new(2024, 8, 31, 23, 59).to_i)
end
it 'handles extreme coordinate values correctly' do
result = edge_stat.calculate_data_bounds
expect(result[:min_lat]).to eq(-90.0)
expect(result[:max_lat]).to eq(90.0)
expect(result[:min_lng]).to eq(-180.0)
expect(result[:max_lng]).to eq(180.0)
expect(result[:point_count]).to eq(2)
end
end
end
context 'when stat has no points' do
let(:empty_user) { create(:user) }
let(:empty_stat) { create(:stat, year: 2024, month: 10, user: empty_user) }
it 'returns nil' do
result = empty_stat.calculate_data_bounds
expect(result).to be_nil
end
end
context 'when stat has points but none within the month timeframe' do
let(:empty_month_user) { create(:user) }
let(:empty_month_stat) { create(:stat, year: 2024, month: 9, user: empty_month_user) }
before do
# Create points outside the target month
create(:point,
user: empty_month_user,
latitude: 40.7,
longitude: -74.0,
timestamp: Time.new(2024, 8, 31, 23, 59).to_i) # August
create(:point,
user: empty_month_user,
latitude: 40.8,
longitude: -73.9,
timestamp: Time.new(2024, 10, 1, 0, 1).to_i) # October
end
it 'returns nil when no points exist in the month' do
result = empty_month_stat.calculate_data_bounds
expect(result).to be_nil
end
end
end
end
end

View file

@ -329,5 +329,11 @@ RSpec.describe User, type: :model do
expect { user.export_data }.to have_enqueued_job(Users::ExportDataJob).with(user.id)
end
end
describe '#timezone' do
it 'returns the app timezone' do
expect(user.timezone).to eq(Time.zone.name)
end
end
end
end

View file

@ -0,0 +1,245 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe HexagonQuery, type: :query do
let(:user) { create(:user) }
let(:min_lon) { -74.1 }
let(:min_lat) { 40.6 }
let(:max_lon) { -73.9 }
let(:max_lat) { 40.8 }
let(:hex_size) { 500 }
describe '#initialize' do
it 'sets required parameters' do
query = described_class.new(
min_lon: min_lon,
min_lat: min_lat,
max_lon: max_lon,
max_lat: max_lat,
hex_size: hex_size
)
expect(query.min_lon).to eq(min_lon)
expect(query.min_lat).to eq(min_lat)
expect(query.max_lon).to eq(max_lon)
expect(query.max_lat).to eq(max_lat)
expect(query.hex_size).to eq(hex_size)
end
it 'sets optional parameters' do
start_date = '2024-06-01T00:00:00Z'
end_date = '2024-06-30T23:59:59Z'
query = described_class.new(
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
)
expect(query.user_id).to eq(user.id)
expect(query.start_date).to eq(start_date)
expect(query.end_date).to eq(end_date)
end
end
describe '#call' do
let(:query) do
described_class.new(
min_lon: min_lon,
min_lat: min_lat,
max_lon: max_lon,
max_lat: max_lat,
hex_size: hex_size,
user_id: user.id
)
end
context 'with no points' do
it 'executes without error and returns empty result' do
result = query.call
expect(result.to_a).to be_empty
end
end
context 'with points in bounding box' do
before do
# Create test points within the bounding box
create(:point,
user:,
latitude: 40.7,
longitude: -74.0,
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
create(:point,
user:,
latitude: 40.75,
longitude: -73.95,
timestamp: Time.new(2024, 6, 16, 14, 0).to_i)
end
it 'returns hexagon results with expected structure' do
result = query.call
result_array = result.to_a
expect(result_array).not_to be_empty
first_hex = result_array.first
expect(first_hex).to have_key('geojson')
expect(first_hex).to have_key('hex_i')
expect(first_hex).to have_key('hex_j')
expect(first_hex).to have_key('point_count')
expect(first_hex).to have_key('earliest_point')
expect(first_hex).to have_key('latest_point')
expect(first_hex).to have_key('id')
# Verify geojson can be parsed
geojson = JSON.parse(first_hex['geojson'])
expect(geojson).to have_key('type')
expect(geojson).to have_key('coordinates')
end
it 'filters by user_id correctly' do
other_user = create(:user)
# Create points for a different user (should be excluded)
create(:point,
user: other_user,
latitude: 40.72,
longitude: -73.98,
timestamp: Time.new(2024, 6, 17, 16, 0).to_i)
result = query.call
result_array = result.to_a
# Should only include hexagons with the specified user's points
total_points = result_array.sum { |row| row['point_count'].to_i }
expect(total_points).to eq(2) # Only the 2 points from our user
end
end
context 'with date filtering' do
let(:query_with_dates) do
described_class.new(
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: '2024-06-15T00:00:00Z',
end_date: '2024-06-16T23:59:59Z'
)
end
before do
# Create points within and outside the date range
create(:point,
user:,
latitude: 40.7,
longitude: -74.0,
timestamp: Time.new(2024, 6, 15, 12, 0).to_i) # Within range
create(:point,
user:,
latitude: 40.71,
longitude: -74.01,
timestamp: Time.new(2024, 6, 20, 12, 0).to_i) # Outside range
end
it 'filters points by date range' do
result = query_with_dates.call
result_array = result.to_a
expect(result_array).not_to be_empty
# Should only include the point within the date range
total_points = result_array.sum { |row| row['point_count'].to_i }
expect(total_points).to eq(1)
end
end
context 'without user_id filter' do
let(:query_no_user) do
described_class.new(
min_lon: min_lon,
min_lat: min_lat,
max_lon: max_lon,
max_lat: max_lat,
hex_size: hex_size
)
end
before do
user1 = create(:user)
user2 = create(:user)
create(:point, user: user1, latitude: 40.7, longitude: -74.0, timestamp: Time.current.to_i)
create(:point, user: user2, latitude: 40.75, longitude: -73.95, timestamp: Time.current.to_i)
end
it 'includes points from all users' do
result = query_no_user.call
result_array = result.to_a
expect(result_array).not_to be_empty
# Should include points from both users
total_points = result_array.sum { |row| row['point_count'].to_i }
expect(total_points).to eq(2)
end
end
end
describe '#build_date_filter (private method behavior)' do
context 'when testing date filter behavior through query execution' do
it 'works correctly with start_date only' do
query = described_class.new(
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: '2024-06-15T00:00:00Z'
)
# Should execute without SQL syntax errors
expect { query.call }.not_to raise_error
end
it 'works correctly with end_date only' do
query = described_class.new(
min_lon: min_lon,
min_lat: min_lat,
max_lon: max_lon,
max_lat: max_lat,
hex_size: hex_size,
user_id: user.id,
end_date: '2024-06-30T23:59:59Z'
)
# Should execute without SQL syntax errors
expect { query.call }.not_to raise_error
end
it 'works correctly with both start_date and end_date' do
query = described_class.new(
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: '2024-06-01T00:00:00Z',
end_date: '2024-06-30T23:59:59Z'
)
# Should execute without SQL syntax errors
expect { query.call }.not_to raise_error
end
end
end
end

View file

@ -49,7 +49,7 @@ RSpec.describe '/api/v1/areas', type: :request do
post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_content)
end
end
end
@ -85,7 +85,7 @@ RSpec.describe '/api/v1/areas', type: :request do
patch api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_content)
end
end
end

View file

@ -0,0 +1,267 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
let(:user) { create(:user) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'GET /api/v1/maps/hexagons' do
let(:valid_params) do
{
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8,
hex_size: 1000,
start_date: '2024-06-01T00:00:00Z',
end_date: '2024-06-30T23:59:59Z'
}
end
context 'with valid API key authentication' do
let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
before do
# Create test points within the date range and bounding box
10.times do |i|
create(:point,
user:,
latitude: 40.7 + (i * 0.001), # Slightly different coordinates
longitude: -74.0 + (i * 0.001),
timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times
end
end
it 'returns hexagon data successfully' do
get '/api/v1/maps/hexagons', params: valid_params, headers: headers
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('type')
expect(json_response['type']).to eq('FeatureCollection')
expect(json_response).to have_key('features')
expect(json_response['features']).to be_an(Array)
end
it 'requires all bbox parameters' do
incomplete_params = valid_params.except(:min_lon)
get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to include('Missing required parameters')
expect(json_response['error']).to include('min_lon')
end
it 'handles service validation errors' do
invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
expect(response).to have_http_status(:bad_request)
end
it 'uses custom hex_size when provided' do
custom_params = valid_params.merge(hex_size: 500)
get '/api/v1/maps/hexagons', params: custom_params, headers: headers
expect(response).to have_http_status(:success)
end
end
context 'with public sharing UUID' do
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
let(:uuid_params) { valid_params.merge(uuid: stat.sharing_uuid) }
before do
# Create test points within the stat's month
15.times do |i|
create(:point,
user:,
latitude: 40.7 + (i * 0.002),
longitude: -74.0 + (i * 0.002),
timestamp: Time.new(2024, 6, 20, 10, i).to_i)
end
end
it 'returns hexagon data without API key' do
get '/api/v1/maps/hexagons', params: uuid_params
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('type')
expect(json_response['type']).to eq('FeatureCollection')
expect(json_response).to have_key('features')
end
it 'uses stat date range automatically' do
# Points outside the stat's month should not be included
5.times do |i|
create(:point,
user:,
latitude: 40.7 + (i * 0.003),
longitude: -74.0 + (i * 0.003),
timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points
end
get '/api/v1/maps/hexagons', params: uuid_params
expect(response).to have_http_status(:success)
end
context 'with invalid sharing UUID' do
it 'returns not found' do
invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid')
get '/api/v1/maps/hexagons', params: invalid_uuid_params
expect(response).to have_http_status(:not_found)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Shared stats not found or no longer available')
end
end
context 'with expired sharing' do
let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
it 'returns not found' do
get '/api/v1/maps/hexagons', params: uuid_params
expect(response).to have_http_status(:not_found)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Shared stats not found or no longer available')
end
end
context 'with disabled sharing' do
let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
it 'returns not found' do
get '/api/v1/maps/hexagons', params: uuid_params
expect(response).to have_http_status(:not_found)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Shared stats not found or no longer available')
end
end
end
context 'without authentication' do
it 'returns unauthorized' do
get '/api/v1/maps/hexagons', params: valid_params
expect(response).to have_http_status(:unauthorized)
end
end
context 'with invalid API key' do
let(:headers) { { 'Authorization' => 'Bearer invalid-key' } }
it 'returns unauthorized' do
get '/api/v1/maps/hexagons', params: valid_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/maps/hexagons/bounds' do
context 'with valid API key authentication' do
let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
let(:date_params) do
{
start_date: Time.new(2024, 6, 1).to_i,
end_date: Time.new(2024, 6, 30, 23, 59, 59).to_i
}
end
before do
# Create test points within the date range
create(:point, user:, latitude: 40.6, longitude: -74.1, timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
create(:point, user:, latitude: 40.8, longitude: -73.9, timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
create(:point, user:, latitude: 40.7, longitude: -74.0, timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
end
it 'returns bounding box for user data' do
get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
expect(json_response['min_lat']).to eq(40.6)
expect(json_response['max_lat']).to eq(40.8)
expect(json_response['min_lng']).to eq(-74.1)
expect(json_response['max_lng']).to eq(-73.9)
expect(json_response['point_count']).to eq(3)
end
it 'returns not found when no points exist in date range' do
get '/api/v1/maps/hexagons/bounds',
params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' },
headers: headers
expect(response).to have_http_status(:not_found)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('No data found for the specified date range')
expect(json_response['point_count']).to eq(0)
end
end
context 'with public sharing UUID' do
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
before do
# Create test points within the stat's month
create(:point, user:, latitude: 41.0, longitude: -74.5, timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
create(:point, user:, latitude: 41.2, longitude: -74.2, timestamp: Time.new(2024, 6, 25, 14, 0).to_i)
end
it 'returns bounds for the shared stat period' do
get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid }
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
expect(json_response['min_lat']).to eq(41.0)
expect(json_response['max_lat']).to eq(41.2)
expect(json_response['point_count']).to eq(2)
end
context 'with invalid sharing UUID' do
it 'returns not found' do
get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' }
expect(response).to have_http_status(:not_found)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Shared stats not found or no longer available')
end
end
end
context 'without authentication' do
it 'returns unauthorized' do
get '/api/v1/maps/hexagons/bounds',
params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' }
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View file

@ -47,7 +47,7 @@ RSpec.describe 'Api::V1::Settings', type: :request do
it 'returns http unprocessable entity' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 'invalid' } }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_content)
end
it 'returns an error message' do

View file

@ -102,7 +102,7 @@ RSpec.describe 'Api::V1::Subscriptions', type: :request do
post '/api/v1/subscriptions/callback', params: { token: token }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_content)
expect(JSON.parse(response.body)['message']).to eq('Invalid subscription data received.')
end
end

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