|
|
@ -1 +1 @@
|
|||
0.31.0
|
||||
0.32.0
|
||||
|
|
|
|||
14
.github/workflows/build_and_push.yml
vendored
|
|
@ -71,9 +71,21 @@ jobs:
|
|||
|
||||
TAGS="freikin/dawarich:${VERSION}"
|
||||
|
||||
# Set platforms based on release type
|
||||
# Set platforms based on version type and release type
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
|
||||
|
||||
# Check if this is a patch version (x.y.z where z > 0)
|
||||
if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then
|
||||
echo "Detected patch version ($VERSION) - building for AMD64 only"
|
||||
PLATFORMS="linux/amd64"
|
||||
elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
|
||||
echo "Detected minor version ($VERSION) - building for all platforms"
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
|
||||
else
|
||||
echo "Version format not recognized or non-semver - using AMD64 only for safety"
|
||||
PLATFORMS="linux/amd64"
|
||||
fi
|
||||
|
||||
# Add :rc tag for pre-releases
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
TAGS="${TAGS},freikin/dawarich:rc"
|
||||
|
|
|
|||
20
CHANGELOG.md
|
|
@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [0.32.0] - 2025-09-13
|
||||
|
||||
## Fixed
|
||||
|
||||
- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
|
||||
- Stats are now being calculated for trial users as well as active ones.
|
||||
|
||||
## Added
|
||||
|
||||
- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
|
||||
- A new month stat page, featuring insights on how user's month went: distance traveled, active days, countries visited and more.
|
||||
- Month stat page can now be shared via public link. User can limit access to the page by sharing period: 1/12/24 hours or permanent.
|
||||
|
||||
## Changed
|
||||
|
||||
- Stats page now loads significantly faster due to caching.
|
||||
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
|
||||
- Minor versions are now being built only for amd64 architecture to speed up the build process.
|
||||
- If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error.
|
||||
|
||||
# [0.31.0] - 2025-09-04
|
||||
|
||||
The Search release
|
||||
|
|
|
|||
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', '~> 2.8'
|
||||
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
|
||||
|
|
|
|||
19
Gemfile.lock
|
|
@ -130,7 +130,7 @@ GEM
|
|||
chunky_png (1.4.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
connection_pool (2.5.4)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
|
|
@ -172,7 +172,8 @@ GEM
|
|||
railties (>= 6.1.0)
|
||||
fakeredis (0.1.4)
|
||||
ffaker (2.24.0)
|
||||
foreman (0.88.1)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
|
|
@ -191,7 +192,7 @@ GEM
|
|||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.1.0)
|
||||
importmap-rails (2.2.2)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
|
|
@ -304,7 +305,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
rack (3.2.0)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
|
|
@ -333,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)
|
||||
|
|
@ -421,11 +425,11 @@ GEM
|
|||
ruby-progressbar (1.13.0)
|
||||
rubyzip (2.4.1)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.33.0)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.26.0)
|
||||
railties (>= 5.0)
|
||||
|
|
@ -541,7 +545,7 @@ DEPENDENCIES
|
|||
groupdate
|
||||
httparty
|
||||
importmap-rails
|
||||
jwt
|
||||
jwt (~> 2.8)
|
||||
kaminari
|
||||
lograge
|
||||
oj
|
||||
|
|
@ -553,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
|
||||
|
||||
|
|
|
|||
143
app/controllers/api/v1/maps/hexagons_controller.rb
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# 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
|
||||
|
||||
# Convert dates to timestamps (handle both string and timestamp formats)
|
||||
start_timestamp = case @start_date
|
||||
when String
|
||||
# Check if it's a numeric string (timestamp) or date string
|
||||
if @start_date.match?(/^\d+$/)
|
||||
@start_date.to_i
|
||||
else
|
||||
Time.parse(@start_date).to_i
|
||||
end
|
||||
when Integer
|
||||
@start_date
|
||||
else
|
||||
@start_date.to_i
|
||||
end
|
||||
end_timestamp = case @end_date
|
||||
when String
|
||||
# Check if it's a numeric string (timestamp) or date string
|
||||
if @end_date.match?(/^\d+$/)
|
||||
@end_date.to_i
|
||||
else
|
||||
Time.parse(@end_date).to_i
|
||||
end
|
||||
when Integer
|
||||
@end_date
|
||||
else
|
||||
@end_date.to_i
|
||||
end
|
||||
|
||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||
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_timestamp, end_timestamp]
|
||||
).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.iso8601
|
||||
@end_date = Date.new(@stat.year, @stat.month, 1).end_of_month.end_of_day.iso8601
|
||||
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 fallback_location: 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,9 +1,9 @@
|
|||
# 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_user!
|
||||
before_action :authenticate_admin!, except: %i[export import]
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
|
|
@ -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).to_i / 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,80 +17,10 @@ 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
|
||||
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next unless country.present?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
next unless toponym['cities'].present?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
|
||||
def year_distance_stat(year, user)
|
||||
# Distance is now stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
221
app/helpers/stats_helper.rb
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StatsHelper
|
||||
def year_distance_stat(year_data, user)
|
||||
total_distance_meters = year_data.sum { _1[1] }
|
||||
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next if country.blank?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
next if toponym['cities'].blank?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
|
||||
def distance_traveled(user, stat)
|
||||
distance_unit = user.safe_settings.distance_unit
|
||||
value = Stat.convert_distance(stat.distance, distance_unit).round
|
||||
|
||||
"#{number_with_delimiter(value)} #{distance_unit}"
|
||||
end
|
||||
|
||||
def x_than_average_distance(stat, average_distance_this_year)
|
||||
return '' if average_distance_this_year&.zero?
|
||||
|
||||
current_km = stat.distance / 1000.0
|
||||
difference = current_km - average_distance_this_year.to_f
|
||||
percentage = ((difference / average_distance_this_year.to_f) * 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_previous_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_unit = stat.user.safe_settings.distance_unit
|
||||
|
||||
distance_value = Stat.convert_distance(peak[1], distance_unit).round
|
||||
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;
|
||||
|
|
@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
|||
queue_as :stats
|
||||
|
||||
def perform
|
||||
user_ids = User.active.pluck(:id)
|
||||
user_ids = User.active.pluck(:id) + User.trial.pluck(:id)
|
||||
|
||||
user_ids.each do |user_id|
|
||||
Stats::BulkCalculator.new(user_id).call
|
||||
|
|
|
|||
18
app/jobs/cache/preheating_job.rb
vendored
|
|
@ -10,6 +10,24 @@ class Cache::PreheatingJob < ApplicationJob
|
|||
user.years_tracked,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_points_geocoded_stats",
|
||||
StatsQuery.new(user).cached_points_geocoded_stats,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_countries_visited",
|
||||
user.countries_visited_uncached,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_cities_visited",
|
||||
user.cities_visited_uncached,
|
||||
expires_in: 1.day
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Lightweight cleanup job that runs weekly to catch any missed track generation.
|
||||
#
|
||||
# This provides a safety net while avoiding the overhead of daily bulk processing.
|
||||
class Tracks::CleanupJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(older_than: 1.day.ago)
|
||||
users_with_old_untracked_points(older_than).find_each do |user|
|
||||
# Process only the old untracked points
|
||||
Tracks::Generator.new(
|
||||
user,
|
||||
end_at: older_than,
|
||||
mode: :incremental
|
||||
).call
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def users_with_old_untracked_points(older_than)
|
||||
User.active.joins(:points)
|
||||
.where(points: { track_id: nil, timestamp: ..older_than.to_i })
|
||||
.having('COUNT(points.id) >= 2') # Only users with enough points for tracks
|
||||
.group(:id)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::CreateJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
|
||||
def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
|
||||
user = User.find(user_id)
|
||||
|
||||
Tracks::Generator.new(user, start_at:, end_at:, mode:).call
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Failed to create tracks for user')
|
||||
end
|
||||
end
|
||||
53
app/jobs/tracks/daily_generation_job.rb
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Daily Track Generation Job
|
||||
#
|
||||
# Automatically processes new location points for all active/trial users on a regular schedule.
|
||||
# This job runs periodically (recommended: every 2-4 hours) to generate tracks from newly
|
||||
# received location data.
|
||||
#
|
||||
# Process:
|
||||
# 1. Iterates through all active or trial users
|
||||
# 2. For each user, finds the timestamp of their last track's end_at
|
||||
# 3. Checks if there are new points since that timestamp
|
||||
# 4. If new points exist, triggers parallel track generation using the existing system
|
||||
# 5. Uses the parallel generator with 'daily' mode for optimal performance
|
||||
#
|
||||
# The job leverages the existing parallel track generation infrastructure,
|
||||
# ensuring consistency with bulk operations while providing automatic daily processing.
|
||||
|
||||
class Tracks::DailyGenerationJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
|
||||
def perform
|
||||
User.active_or_trial.find_each do |user|
|
||||
next if user.points_count.zero?
|
||||
|
||||
process_user_daily_tracks(user)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to process daily tracks for user #{user.id}")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_user_daily_tracks(user)
|
||||
start_timestamp = start_timestamp(user)
|
||||
|
||||
return unless user.points.where('timestamp >= ?', start_timestamp).exists?
|
||||
|
||||
Tracks::ParallelGeneratorJob.perform_later(
|
||||
user.id,
|
||||
start_at: start_timestamp,
|
||||
end_at: Time.current.to_i,
|
||||
mode: 'daily'
|
||||
)
|
||||
end
|
||||
|
||||
def start_timestamp(user)
|
||||
last_end = user.tracks.maximum(:end_at)&.to_i
|
||||
return last_end + 1 if last_end
|
||||
|
||||
user.points.minimum(:timestamp) || 1.week.ago.to_i
|
||||
end
|
||||
end
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::IncrementalCheckJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
|
||||
def perform(user_id, point_id)
|
||||
user = User.find(user_id)
|
||||
point = Point.find(point_id)
|
||||
|
||||
Tracks::IncrementalProcessor.new(user, point).call
|
||||
end
|
||||
end
|
||||
|
|
@ -8,7 +8,7 @@ class Tracks::ParallelGeneratorJob < ApplicationJob
|
|||
def perform(user_id, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day)
|
||||
user = User.find(user_id)
|
||||
|
||||
session = Tracks::ParallelGenerator.new(
|
||||
Tracks::ParallelGenerator.new(
|
||||
user,
|
||||
start_at: start_at,
|
||||
end_at: end_at,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
|
|||
|
||||
tracks_created = process_chunk
|
||||
update_session_progress(tracks_created)
|
||||
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to process time chunk for user #{user_id}")
|
||||
|
||||
|
|
@ -48,9 +47,7 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
|
|||
# Create tracks from segments
|
||||
tracks_created = 0
|
||||
segments.each do |segment_points|
|
||||
if create_track_from_points_array(segment_points)
|
||||
tracks_created += 1
|
||||
end
|
||||
tracks_created += 1 if create_track_from_points_array(segment_points)
|
||||
end
|
||||
|
||||
tracks_created
|
||||
|
|
|
|||
|
|
@ -6,19 +6,46 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
def perform(user_id, email_type, **options)
|
||||
user = User.find(user_id)
|
||||
|
||||
if trial_related_email?(email_type) && user.active?
|
||||
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed"
|
||||
if should_skip_email?(user, email_type)
|
||||
ExceptionReporter.call(
|
||||
'Users::MailerSendingJob',
|
||||
"Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
|
||||
)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
params = { user: user }.merge(options)
|
||||
|
||||
UsersMailer.with(params).public_send(email_type).deliver_later
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
ExceptionReporter.call(
|
||||
'Users::MailerSendingJob',
|
||||
"User with ID #{user_id} not found. Skipping #{email_type} email."
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trial_related_email?(email_type)
|
||||
%w[trial_expires_soon trial_expired].include?(email_type.to_s)
|
||||
def should_skip_email?(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
user.active?
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? || !user.trial?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def skip_reason(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
'user is already subscribed'
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? ? 'user is subscribed' : 'user is not in trial state'
|
||||
else
|
||||
'unknown reason'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,26 +2,44 @@
|
|||
|
||||
class UsersMailer < ApplicationMailer
|
||||
def welcome
|
||||
# Sent after user signs up
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: 'Welcome to Dawarich!')
|
||||
end
|
||||
|
||||
def explore_features
|
||||
# Sent 2 days after user signs up
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: 'Explore Dawarich features!')
|
||||
end
|
||||
|
||||
def trial_expires_soon
|
||||
# Sent 2 days before trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days')
|
||||
end
|
||||
|
||||
def trial_expired
|
||||
# Sent when trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
|
||||
end
|
||||
|
||||
def post_trial_reminder_early
|
||||
# Sent 2 days after trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!')
|
||||
end
|
||||
|
||||
def post_trial_reminder_late
|
||||
# Sent 7 days after trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ class Point < ApplicationRecord
|
|||
index: true
|
||||
}
|
||||
|
||||
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 }, suffix: true
|
||||
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 },
|
||||
suffix: true
|
||||
enum :trigger, {
|
||||
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
|
||||
report_location_message_event: 4, manual_event: 5, timer_based_event: 6,
|
||||
|
|
@ -33,7 +34,6 @@ class Point < ApplicationRecord
|
|||
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? }
|
||||
after_create :set_country
|
||||
after_create_commit :broadcast_coordinates
|
||||
# after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
|
||||
# after_commit :recalculate_track, on: :update, if: -> { track.present? }
|
||||
|
||||
def self.without_raw_data
|
||||
|
|
@ -68,7 +68,7 @@ class Point < ApplicationRecord
|
|||
|
||||
def country_name
|
||||
# TODO: Remove the country column in the future.
|
||||
read_attribute(:country_name) || self.country&.name || read_attribute(:country) || ''
|
||||
read_attribute(:country_name) || country&.name || self[:country] || ''
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -101,8 +101,4 @@ class Point < ApplicationRecord
|
|||
def recalculate_track
|
||||
track.recalculate_path_and_distance!
|
||||
end
|
||||
|
||||
def trigger_incremental_track_generation
|
||||
Tracks::IncrementalCheckJob.perform_later(user.id, id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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,96 @@ class Stat < ApplicationRecord
|
|||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def sharing_enabled?
|
||||
sharing_settings['enabled'] == true
|
||||
end
|
||||
|
||||
def sharing_expired?
|
||||
expiration = sharing_settings['expiration']
|
||||
return false if expiration.blank? || expiration == 'permanent'
|
||||
|
||||
expires_at_value = sharing_settings['expires_at']
|
||||
return true if expires_at_value.blank?
|
||||
|
||||
expires_at = begin
|
||||
Time.zone.parse(expires_at_value)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
expires_at.present? ? Time.current > expires_at : true
|
||||
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' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
when '24h' then 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(ST_Y(lonlat::geometry)) as min_lat, MAX(ST_Y(lonlat::geometry)) as max_lat,
|
||||
MIN(ST_X(lonlat::geometry)) as min_lng, MAX(ST_X(lonlat::geometry)) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3
|
||||
AND lonlat IS NOT NULL",
|
||||
'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 +130,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
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
before_save :sanitize_input
|
||||
|
||||
validates :email, presence: true
|
||||
|
||||
validates :reset_password_token, uniqueness: true, allow_nil: true
|
||||
|
||||
attribute :admin, :boolean, default: false
|
||||
attribute :points_count, :integer, default: 0
|
||||
|
||||
scope :active_or_trial, -> { where(status: %i[active trial]) }
|
||||
|
||||
enum :status, { inactive: 0, active: 1, trial: 2 }
|
||||
|
||||
def safe_settings
|
||||
|
|
@ -35,15 +36,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
end
|
||||
|
||||
def countries_visited
|
||||
points
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
end
|
||||
|
||||
def cities_visited
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
end
|
||||
|
||||
def total_distance
|
||||
|
|
@ -121,6 +127,23 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
(points_count || 0).zero? && trial?
|
||||
end
|
||||
|
||||
def timezone
|
||||
Time.zone.name
|
||||
end
|
||||
|
||||
def countries_visited_uncached
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
|
||||
def cities_visited_uncached
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_api_key
|
||||
|
|
@ -151,5 +174,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
|
||||
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
|
||||
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
|
||||
schedule_post_trial_emails
|
||||
end
|
||||
|
||||
def schedule_post_trial_emails
|
||||
Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
|
||||
Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
148
app/queries/hexagon_query.rb
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# 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
|
||||
binds = []
|
||||
user_sql = build_user_filter(binds)
|
||||
date_filter = build_date_filter(binds)
|
||||
|
||||
sql = build_hexagon_sql(user_sql, date_filter)
|
||||
|
||||
ActiveRecord::Base.connection.exec_query(sql, 'hexagon_sql', binds)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_hexagon_sql(user_sql, date_filter)
|
||||
<<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope($1, $2, $3, $4, 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_sql}
|
||||
#{date_filter}
|
||||
AND ST_Intersects(
|
||||
lonlat,
|
||||
(SELECT geom FROM bbox_geom)::geometry
|
||||
)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid($5, 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 $6;
|
||||
SQL
|
||||
end
|
||||
|
||||
def build_user_filter(binds)
|
||||
# Add bbox coordinates: min_lon, min_lat, max_lon, max_lat
|
||||
binds << min_lon
|
||||
binds << min_lat
|
||||
binds << max_lon
|
||||
binds << max_lat
|
||||
|
||||
# Add hex_size
|
||||
binds << hex_size
|
||||
|
||||
# Add limit
|
||||
binds << MAX_HEXAGONS_PER_REQUEST
|
||||
|
||||
if user_id
|
||||
binds << user_id
|
||||
'user_id = $7'
|
||||
else
|
||||
'1=1'
|
||||
end
|
||||
end
|
||||
|
||||
def build_date_filter(binds)
|
||||
return '' unless start_date || end_date
|
||||
|
||||
conditions = []
|
||||
current_param_index = user_id ? 8 : 7 # Account for bbox, hex_size, limit, and potential user_id
|
||||
|
||||
if start_date
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
binds << start_timestamp
|
||||
conditions << "timestamp >= $#{current_param_index}"
|
||||
current_param_index += 1
|
||||
end
|
||||
|
||||
if end_date
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
binds << end_timestamp
|
||||
conditions << "timestamp <= $#{current_param_index}"
|
||||
end
|
||||
|
||||
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
|
||||
end
|
||||
|
||||
def parse_date_to_timestamp(date_string)
|
||||
# Convert ISO date string to timestamp integer
|
||||
Time.parse(date_string).to_i
|
||||
rescue ArgumentError => e
|
||||
ExceptionReporter.call(e, "Invalid date format: #{date_string}")
|
||||
raise ArgumentError, "Invalid date format: #{date_string}"
|
||||
end
|
||||
end
|
||||
|
|
@ -6,22 +6,34 @@ class StatsQuery
|
|||
end
|
||||
|
||||
def points_stats
|
||||
sql = ActiveRecord::Base.sanitize_sql_array([
|
||||
<<~SQL.squish,
|
||||
SELECT
|
||||
COUNT(id) as total,
|
||||
COUNT(reverse_geocoded_at) as geocoded,
|
||||
COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
|
||||
FROM points
|
||||
WHERE user_id = ?
|
||||
SQL
|
||||
user.id
|
||||
])
|
||||
cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do
|
||||
cached_points_geocoded_stats
|
||||
end
|
||||
|
||||
{
|
||||
total: user.points_count,
|
||||
geocoded: cached_stats[:geocoded],
|
||||
without_data: cached_stats[:without_data]
|
||||
}
|
||||
end
|
||||
|
||||
def cached_points_geocoded_stats
|
||||
sql = ActiveRecord::Base.sanitize_sql_array(
|
||||
[
|
||||
<<~SQL.squish,
|
||||
SELECT
|
||||
COUNT(reverse_geocoded_at) as geocoded,
|
||||
COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
|
||||
FROM points
|
||||
WHERE user_id = ?
|
||||
SQL
|
||||
user.id
|
||||
]
|
||||
)
|
||||
|
||||
result = Point.connection.select_one(sql)
|
||||
|
||||
{
|
||||
total: result['total'].to_i,
|
||||
geocoded: result['geocoded'].to_i,
|
||||
without_data: result['without_data'].to_i
|
||||
}
|
||||
|
|
|
|||
15
app/services/cache/clean.rb
vendored
|
|
@ -7,6 +7,8 @@ class Cache::Clean
|
|||
delete_control_flag
|
||||
delete_version_cache
|
||||
delete_years_tracked_cache
|
||||
delete_points_geocoded_stats_cache
|
||||
delete_countries_cities_cache
|
||||
Rails.logger.info('Cache cleaned')
|
||||
end
|
||||
|
||||
|
|
@ -25,5 +27,18 @@ class Cache::Clean
|
|||
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_points_geocoded_stats_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_countries_cities_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_countries")
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_cities")
|
||||
end
|
||||
end
|
||||
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' => start_date, 'end_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
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This service handles both bulk and incremental track generation using a unified
|
||||
# approach with different modes:
|
||||
#
|
||||
# - :bulk - Regenerates all tracks from scratch (replaces existing)
|
||||
# - :incremental - Processes untracked points up to a specified end time
|
||||
# - :daily - Processes tracks on a daily basis
|
||||
#
|
||||
# Key features:
|
||||
# - Deterministic results (same algorithm for all modes)
|
||||
# - Simple incremental processing without buffering complexity
|
||||
# - Configurable time and distance thresholds from user settings
|
||||
# - Automatic track statistics calculation
|
||||
# - Proper handling of edge cases (empty points, incomplete segments)
|
||||
#
|
||||
# Usage:
|
||||
# # Bulk regeneration
|
||||
# Tracks::Generator.new(user, mode: :bulk).call
|
||||
#
|
||||
# # Incremental processing
|
||||
# Tracks::Generator.new(user, mode: :incremental).call
|
||||
#
|
||||
# # Daily processing
|
||||
# Tracks::Generator.new(user, start_at: Date.current, mode: :daily).call
|
||||
#
|
||||
class Tracks::Generator
|
||||
include Tracks::Segmentation
|
||||
include Tracks::TrackBuilder
|
||||
|
||||
attr_reader :user, :start_at, :end_at, :mode
|
||||
|
||||
def initialize(user, start_at: nil, end_at: nil, mode: :bulk)
|
||||
@user = user
|
||||
@start_at = start_at
|
||||
@end_at = end_at
|
||||
@mode = mode.to_sym
|
||||
end
|
||||
|
||||
def call
|
||||
clean_existing_tracks if should_clean_tracks?
|
||||
|
||||
start_timestamp, end_timestamp = get_timestamp_range
|
||||
|
||||
segments = Track.get_segments_with_points(
|
||||
user.id,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
time_threshold_minutes,
|
||||
distance_threshold_meters,
|
||||
untracked_only: mode == :incremental
|
||||
)
|
||||
|
||||
tracks_created = 0
|
||||
|
||||
segments.each do |segment|
|
||||
track = create_track_from_segment(segment)
|
||||
tracks_created += 1 if track
|
||||
end
|
||||
|
||||
tracks_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_clean_tracks?
|
||||
case mode
|
||||
when :bulk, :daily then true
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def load_points
|
||||
case mode
|
||||
when :bulk then load_bulk_points
|
||||
when :incremental then load_incremental_points
|
||||
when :daily then load_daily_points
|
||||
else
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def load_bulk_points
|
||||
scope = user.points.order(:timestamp)
|
||||
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def load_incremental_points
|
||||
# For incremental mode, we process untracked points
|
||||
# If end_at is specified, only process points up to that time
|
||||
scope = user.points.where(track_id: nil).order(:timestamp)
|
||||
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def load_daily_points
|
||||
day_range = daily_time_range
|
||||
|
||||
user.points.where(timestamp: day_range).order(:timestamp)
|
||||
end
|
||||
|
||||
def create_track_from_segment(segment_data)
|
||||
points = segment_data[:points]
|
||||
pre_calculated_distance = segment_data[:pre_calculated_distance]
|
||||
|
||||
return unless points.size >= 2
|
||||
|
||||
create_track_from_points(points, pre_calculated_distance)
|
||||
end
|
||||
|
||||
def time_range_defined?
|
||||
start_at.present? || end_at.present?
|
||||
end
|
||||
|
||||
def time_range
|
||||
return nil unless time_range_defined?
|
||||
|
||||
start_time = start_at&.to_i
|
||||
end_time = end_at&.to_i
|
||||
|
||||
if start_time && end_time
|
||||
Time.zone.at(start_time)..Time.zone.at(end_time)
|
||||
elsif start_time
|
||||
Time.zone.at(start_time)..
|
||||
elsif end_time
|
||||
..Time.zone.at(end_time)
|
||||
end
|
||||
end
|
||||
|
||||
def timestamp_range
|
||||
return nil unless time_range_defined?
|
||||
|
||||
start_time = start_at&.to_i
|
||||
end_time = end_at&.to_i
|
||||
|
||||
if start_time && end_time
|
||||
start_time..end_time
|
||||
elsif start_time
|
||||
start_time..
|
||||
elsif end_time
|
||||
..end_time
|
||||
end
|
||||
end
|
||||
|
||||
def daily_time_range
|
||||
day = start_at&.to_date || Date.current
|
||||
day.beginning_of_day.to_i..day.end_of_day.to_i
|
||||
end
|
||||
|
||||
def clean_existing_tracks
|
||||
case mode
|
||||
when :bulk then clean_bulk_tracks
|
||||
when :daily then clean_daily_tracks
|
||||
else
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def clean_bulk_tracks
|
||||
scope = user.tracks
|
||||
scope = scope.where(start_at: time_range) if time_range_defined?
|
||||
|
||||
scope.destroy_all
|
||||
end
|
||||
|
||||
def clean_daily_tracks
|
||||
day_range = daily_time_range
|
||||
range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
|
||||
|
||||
scope = user.tracks.where(start_at: range)
|
||||
scope.destroy_all
|
||||
end
|
||||
|
||||
def get_timestamp_range
|
||||
case mode
|
||||
when :bulk then bulk_timestamp_range
|
||||
when :daily then daily_timestamp_range
|
||||
when :incremental then incremental_timestamp_range
|
||||
else
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_timestamp_range
|
||||
return [start_at.to_i, end_at.to_i] if start_at && end_at
|
||||
|
||||
first_point = user.points.order(:timestamp).first
|
||||
last_point = user.points.order(:timestamp).last
|
||||
|
||||
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
|
||||
end
|
||||
|
||||
def daily_timestamp_range
|
||||
day = start_at&.to_date || Date.current
|
||||
[day.beginning_of_day.to_i, day.end_of_day.to_i]
|
||||
end
|
||||
|
||||
def incremental_timestamp_range
|
||||
first_point = user.points.where(track_id: nil).order(:timestamp).first
|
||||
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
|
||||
|
||||
[first_point&.timestamp || 0, end_timestamp]
|
||||
end
|
||||
|
||||
def distance_threshold_meters
|
||||
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||
end
|
||||
|
||||
def time_threshold_minutes
|
||||
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||
end
|
||||
end
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This service analyzes new points as they're created and determines whether
|
||||
# they should trigger incremental track generation based on time and distance
|
||||
# thresholds defined in user settings.
|
||||
#
|
||||
# The key insight is that we should trigger track generation when there's a
|
||||
# significant gap between the new point and the previous point, indicating
|
||||
# the end of a journey and the start of a new one.
|
||||
#
|
||||
# Process:
|
||||
# 1. Check if the new point should trigger processing (skip imported points)
|
||||
# 2. Find the last point before the new point
|
||||
# 3. Calculate time and distance differences
|
||||
# 4. If thresholds are exceeded, trigger incremental generation
|
||||
# 5. Set the end_at time to the previous point's timestamp for track finalization
|
||||
#
|
||||
# This ensures tracks are properly finalized when journeys end, not when they start.
|
||||
#
|
||||
# Usage:
|
||||
# # In Point model after_create_commit callback
|
||||
# Tracks::IncrementalProcessor.new(user, new_point).call
|
||||
#
|
||||
class Tracks::IncrementalProcessor
|
||||
attr_reader :user, :new_point, :previous_point
|
||||
|
||||
def initialize(user, new_point)
|
||||
@user = user
|
||||
@new_point = new_point
|
||||
@previous_point = find_previous_point
|
||||
end
|
||||
|
||||
def call
|
||||
return unless should_process?
|
||||
|
||||
start_at = find_start_time
|
||||
end_at = find_end_time
|
||||
|
||||
Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_process?
|
||||
return false if new_point.import_id.present?
|
||||
return true unless previous_point
|
||||
|
||||
exceeds_thresholds?(previous_point, new_point)
|
||||
end
|
||||
|
||||
def find_previous_point
|
||||
@previous_point ||=
|
||||
user.points
|
||||
.where('timestamp < ?', new_point.timestamp)
|
||||
.order(:timestamp)
|
||||
.last
|
||||
end
|
||||
|
||||
def find_start_time
|
||||
user.tracks.order(:end_at).last&.end_at
|
||||
end
|
||||
|
||||
def find_end_time
|
||||
previous_point ? Time.zone.at(previous_point.timestamp) : nil
|
||||
end
|
||||
|
||||
def exceeds_thresholds?(previous_point, current_point)
|
||||
time_gap = time_difference_minutes(previous_point, current_point)
|
||||
distance_gap = distance_difference_meters(previous_point, current_point)
|
||||
|
||||
time_exceeded = time_gap >= time_threshold_minutes
|
||||
distance_exceeded = distance_gap >= distance_threshold_meters
|
||||
|
||||
time_exceeded || distance_exceeded
|
||||
end
|
||||
|
||||
def time_difference_minutes(point1, point2)
|
||||
(point2.timestamp - point1.timestamp) / 60.0
|
||||
end
|
||||
|
||||
def distance_difference_meters(point1, point2)
|
||||
point1.distance_to(point2) * 1000
|
||||
end
|
||||
|
||||
def time_threshold_minutes
|
||||
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||
end
|
||||
|
||||
def distance_threshold_meters
|
||||
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||
end
|
||||
end
|
||||
|
|
@ -17,8 +17,7 @@ class Tracks::ParallelGenerator
|
|||
end
|
||||
|
||||
def call
|
||||
# Clean existing tracks if needed
|
||||
clean_existing_tracks if should_clean_tracks?
|
||||
clean_bulk_tracks if mode == :bulk
|
||||
|
||||
# Generate time chunks
|
||||
time_chunks = generate_time_chunks
|
||||
|
|
@ -40,13 +39,6 @@ class Tracks::ParallelGenerator
|
|||
|
||||
private
|
||||
|
||||
def should_clean_tracks?
|
||||
case mode
|
||||
when :bulk, :daily then true
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def generate_time_chunks
|
||||
chunker = Tracks::TimeChunker.new(
|
||||
user,
|
||||
|
|
@ -95,30 +87,18 @@ class Tracks::ParallelGenerator
|
|||
)
|
||||
end
|
||||
|
||||
def clean_existing_tracks
|
||||
case mode
|
||||
when :bulk then clean_bulk_tracks
|
||||
when :daily then clean_daily_tracks
|
||||
else
|
||||
raise ArgumentError, "Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def clean_bulk_tracks
|
||||
if time_range_defined?
|
||||
user.tracks.where(start_at: time_range).destroy_all
|
||||
user.tracks.where(
|
||||
'(start_at, end_at) OVERLAPS (?, ?)',
|
||||
start_at&.in_time_zone,
|
||||
end_at&.in_time_zone
|
||||
).destroy_all
|
||||
else
|
||||
user.tracks.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def clean_daily_tracks
|
||||
day_range = daily_time_range
|
||||
range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
|
||||
|
||||
user.tracks.where(start_at: range).destroy_all
|
||||
end
|
||||
|
||||
def time_range_defined?
|
||||
start_at.present? || end_at.present?
|
||||
end
|
||||
|
|
@ -162,8 +142,8 @@ class Tracks::ParallelGenerator
|
|||
else
|
||||
# Convert seconds to readable format
|
||||
seconds = duration.to_i
|
||||
if seconds >= 86400 # days
|
||||
days = seconds / 86400
|
||||
if seconds >= 86_400 # days
|
||||
days = seconds / 86_400
|
||||
"#{days} day#{'s' if days != 1}"
|
||||
elsif seconds >= 3600 # hours
|
||||
hours = seconds / 3600
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@
|
|||
# time_threshold_minutes methods.
|
||||
#
|
||||
# Used by:
|
||||
# - Tracks::Generator for splitting points during track generation
|
||||
# - Tracks::CreateFromPoints for legacy compatibility
|
||||
# - Tracks::ParallelGenerator and related jobs for splitting points during parallel track generation
|
||||
# - Tracks::BoundaryDetector for cross-chunk track merging
|
||||
#
|
||||
# Example usage:
|
||||
# class MyTrackProcessor
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ class Tracks::SessionManager
|
|||
}
|
||||
|
||||
Rails.cache.write(cache_key, session_data, expires_in: DEFAULT_TTL)
|
||||
# Initialize counters atomically using Redis SET
|
||||
Rails.cache.redis.with do |redis|
|
||||
redis.set(counter_key('completed_chunks'), 0, ex: DEFAULT_TTL.to_i)
|
||||
redis.set(counter_key('tracks_created'), 0, ex: DEFAULT_TTL.to_i)
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
|
|
@ -44,8 +50,10 @@ class Tracks::SessionManager
|
|||
def get_session_data
|
||||
data = Rails.cache.read(cache_key)
|
||||
return nil unless data
|
||||
|
||||
# Rails.cache already deserializes the data, no need for JSON parsing
|
||||
|
||||
# Include current counter values
|
||||
data['completed_chunks'] = counter_value('completed_chunks')
|
||||
data['tracks_created'] = counter_value('tracks_created')
|
||||
data
|
||||
end
|
||||
|
||||
|
|
@ -65,20 +73,18 @@ class Tracks::SessionManager
|
|||
|
||||
# Increment completed chunks
|
||||
def increment_completed_chunks
|
||||
session_data = get_session_data
|
||||
return false unless session_data
|
||||
return false unless session_exists?
|
||||
|
||||
new_completed = session_data['completed_chunks'] + 1
|
||||
update_session(completed_chunks: new_completed)
|
||||
atomic_increment(counter_key('completed_chunks'), 1)
|
||||
true
|
||||
end
|
||||
|
||||
# Increment tracks created
|
||||
def increment_tracks_created(count = 1)
|
||||
session_data = get_session_data
|
||||
return false unless session_data
|
||||
return false unless session_exists?
|
||||
|
||||
new_count = session_data['tracks_created'] + count
|
||||
update_session(tracks_created: new_count)
|
||||
atomic_increment(counter_key('tracks_created'), count)
|
||||
true
|
||||
end
|
||||
|
||||
# Mark session as completed
|
||||
|
|
@ -103,7 +109,8 @@ class Tracks::SessionManager
|
|||
session_data = get_session_data
|
||||
return false unless session_data
|
||||
|
||||
session_data['completed_chunks'] >= session_data['total_chunks']
|
||||
completed_chunks = counter_value('completed_chunks')
|
||||
completed_chunks >= session_data['total_chunks']
|
||||
end
|
||||
|
||||
# Get progress percentage
|
||||
|
|
@ -114,13 +121,16 @@ class Tracks::SessionManager
|
|||
total = session_data['total_chunks']
|
||||
return 100 if total.zero?
|
||||
|
||||
completed = session_data['completed_chunks']
|
||||
completed = counter_value('completed_chunks')
|
||||
(completed.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
# Delete session
|
||||
def cleanup_session
|
||||
Rails.cache.delete(cache_key)
|
||||
Rails.cache.redis.with do |redis|
|
||||
redis.del(counter_key('completed_chunks'), counter_key('tracks_created'))
|
||||
end
|
||||
end
|
||||
|
||||
# Class methods for session management
|
||||
|
|
@ -149,4 +159,20 @@ class Tracks::SessionManager
|
|||
def cache_key
|
||||
"#{CACHE_KEY_PREFIX}:user:#{user_id}:session:#{session_id}"
|
||||
end
|
||||
end
|
||||
|
||||
def counter_key(field)
|
||||
"#{cache_key}:#{field}"
|
||||
end
|
||||
|
||||
def counter_value(field)
|
||||
Rails.cache.redis.with do |redis|
|
||||
(redis.get(counter_key(field)) || 0).to_i
|
||||
end
|
||||
end
|
||||
|
||||
def atomic_increment(key, amount)
|
||||
Rails.cache.redis.with do |redis|
|
||||
redis.incrby(key, amount)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
# This ensures consistency when users change their distance unit preferences.
|
||||
#
|
||||
# Used by:
|
||||
# - Tracks::Generator for creating tracks during generation
|
||||
# - Tracks::ParallelGenerator and related jobs for creating tracks during parallel generation
|
||||
# - Any class that needs to convert point arrays to Track records
|
||||
#
|
||||
# Example usage:
|
||||
|
|
@ -60,7 +60,7 @@ module Tracks::TrackBuilder
|
|||
)
|
||||
|
||||
# TODO: Move trips attrs to columns with more precision and range
|
||||
track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max
|
||||
track.distance = [[pre_calculated_distance.round, 999_999].min, 0].max
|
||||
track.duration = calculate_duration(points)
|
||||
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ module Tracks::TrackBuilder
|
|||
speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h
|
||||
|
||||
# Cap the speed to prevent database precision overflow (max 999999.99)
|
||||
[speed_kmh, 999999.99].min
|
||||
[speed_kmh, 999_999.99].min
|
||||
end
|
||||
|
||||
def calculate_elevation_stats(points)
|
||||
|
|
@ -145,6 +145,6 @@ module Tracks::TrackBuilder
|
|||
private
|
||||
|
||||
def user
|
||||
raise NotImplementedError, "Including class must implement user method"
|
||||
raise NotImplementedError, 'Including class must implement user method'
|
||||
end
|
||||
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? ? shared_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_previous_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 %>
|
||||
|
|
|
|||