Merge pull request #1757 from Freika/feature/stats-page
Feature/stats page
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
|
@ -92,7 +92,7 @@
|
|||
}
|
||||
|
||||
.loading-spinner::before {
|
||||
content: '🔵';
|
||||
content: '';
|
||||
font-size: 18px;
|
||||
animation: spinner 1s linear infinite;
|
||||
}
|
||||
|
|
|
|||
13
app/assets/svg/icons/lucide/outline/activity.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/bell.svg
Normal 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 |
23
app/assets/svg/icons/lucide/outline/building.svg
Normal 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 |
19
app/assets/svg/icons/lucide/outline/bus.svg
Normal 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 |
17
app/assets/svg/icons/lucide/outline/calendar-check-2.svg
Normal 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 |
14
app/assets/svg/icons/lucide/outline/camera.svg
Normal 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 |
16
app/assets/svg/icons/lucide/outline/car.svg
Normal 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 |
14
app/assets/svg/icons/lucide/outline/copy.svg
Normal 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 |
16
app/assets/svg/icons/lucide/outline/earth.svg
Normal 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 |
13
app/assets/svg/icons/lucide/outline/flame.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/flower.svg
Normal 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 |
15
app/assets/svg/icons/lucide/outline/globe.svg
Normal 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 |
14
app/assets/svg/icons/lucide/outline/house.svg
Normal 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 |
15
app/assets/svg/icons/lucide/outline/info.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/leaf.svg
Normal 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 |
15
app/assets/svg/icons/lucide/outline/lightbulb.svg
Normal 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 |
14
app/assets/svg/icons/lucide/outline/link.svg
Normal 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 |
16
app/assets/svg/icons/lucide/outline/map-pin-plus.svg
Normal 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 |
14
app/assets/svg/icons/lucide/outline/map-pin.svg
Normal 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 |
17
app/assets/svg/icons/lucide/outline/map-plus.svg
Normal 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 |
15
app/assets/svg/icons/lucide/outline/map.svg
Normal 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 |
13
app/assets/svg/icons/lucide/outline/plane.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/refresh-ccw.svg
Normal 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 |
15
app/assets/svg/icons/lucide/outline/share.svg
Normal 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 |
15
app/assets/svg/icons/lucide/outline/shopping-cart.svg
Normal 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 |
24
app/assets/svg/icons/lucide/outline/snowflake.svg
Normal 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 |
13
app/assets/svg/icons/lucide/outline/star.svg
Normal 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 |
1
app/assets/svg/icons/lucide/outline/tree-palm.svg
Normal 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 |
14
app/assets/svg/icons/lucide/outline/trending-up.svg
Normal 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 |
18
app/assets/svg/icons/lucide/outline/trophy.svg
Normal 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 |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
115
app/controllers/api/v1/maps/hexagons_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
app/controllers/shared/stats_controller.rb
Normal 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
|
||||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
309
app/javascript/controllers/public_stat_map_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/javascript/controllers/sharing_modal_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
287
app/javascript/controllers/stat_page_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
363
app/javascript/maps/hexagon_grid.js
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
app/queries/hexagon_query.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
153
app/services/maps/hexagon_grid.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
98
app/views/shared/_sharing_modal.html.erb
Normal 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>
|
||||
320
app/views/stats/_month.html.erb
Normal 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' %>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
5
app/views/stats/month.html.erb
Normal 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>
|
||||
185
app/views/stats/public_month.html.erb
Normal 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>
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
14
config/initializers/rails_icons.rb
Normal 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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
db/migrate/20250910224538_add_sharing_fields_to_stats.rb
Normal 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
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
245
spec/queries/hexagon_query_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
267
spec/requests/api/v1/maps/hexagons_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||