mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Compare commits
53 commits
d19fa836f3
...
63fc8ca84e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63fc8ca84e | ||
|
|
b4c2def2be | ||
|
|
8a1e42a2e8 | ||
|
|
2f11003c29 | ||
|
|
8d2ade1bdc | ||
|
|
7f277612fc | ||
|
|
2f5487cd35 | ||
|
|
5455228b80 | ||
|
|
26062a1278 | ||
|
|
14a0bb6478 | ||
|
|
18b13fb915 | ||
|
|
e857f520cc | ||
|
|
9e933aff9c | ||
|
|
a722e19a93 | ||
|
|
67d7123e47 | ||
|
|
573d527455 | ||
|
|
4be58d4b4c | ||
|
|
3f436c1d3a | ||
|
|
fe9d7d2f79 | ||
|
|
fab0121113 | ||
|
|
9805c5524c | ||
|
|
f325fd7a4f | ||
|
|
3c1d17b806 | ||
|
|
c9ba7914b6 | ||
|
|
03697ecef2 | ||
|
|
7347be9a87 | ||
|
|
ce74b3d846 | ||
|
|
da9742bf4a | ||
|
|
e12b45f93e | ||
|
|
3f0aaa09f5 | ||
|
|
32f5d2f89a | ||
|
|
ad385f4464 | ||
|
|
d4e87ce830 | ||
|
|
04fbe4d564 | ||
|
|
2a1584e0b8 | ||
|
|
1471e4de40 | ||
|
|
9ef0da27d6 | ||
|
|
87baf8bb11 | ||
|
|
d40b2a1959 | ||
|
|
35995e7be8 | ||
|
|
c8242ce902 | ||
|
|
20a4553921 | ||
|
|
c1bb7f3d87 | ||
|
|
0b6149bfc0 | ||
|
|
f2d96e50f0 | ||
|
|
1090bcd6e8 | ||
|
|
b7f0b7ebc2 | ||
|
|
b81d2580e3 | ||
|
|
acee848e72 | ||
|
|
88f5e2a6ea | ||
|
|
543242cdf3 | ||
|
|
e5f52a6125 | ||
|
|
8bfce7ccb6 |
128 changed files with 6414 additions and 456 deletions
|
|
@ -1 +1 @@
|
||||||
0.36.3
|
0.37.1
|
||||||
|
|
|
||||||
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -4,12 +4,54 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
# [0.36.3] - Unreleased
|
# [0.37.1] - 2025-12-30
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- The db migration preventing the app from starting.
|
||||||
|
- Raw data archive verifier now allows having points deleted from the db after archiving.
|
||||||
|
|
||||||
|
# [0.37.0] - 2025-12-30
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- In the beginning of the year users will receive a year-end digest email with stats about their tracking activity during the past year. Users can opt out of receiving these emails in User Settings -> Notifications. Emails won't be sent if no email is configured in the SMTP settings or if user has no points tracked during the year.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Added and removed some indexes to improve the app performance based on the production usage data.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Deleting an import will no longer result in negative points count for the user.
|
||||||
|
- Updating stats. #2022
|
||||||
|
- Validate trip start date to be earlier than end date. #2057
|
||||||
|
- Fog of war radius slider in map v2 settings is now being respected correctly. #2041
|
||||||
|
- Applying changes in map v2 settings now works correctly. #2041
|
||||||
|
- Invalidate stats cache on recalculation and other operations that change stats data.
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.4] - 2025-12-26
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Fixed a bug preventing the app to start if a composite index on stats table already exists. #2034 #2051 #2046
|
||||||
|
- New compiled assets will override old ones on app start to prevent serving stale assets.
|
||||||
|
- Number of points in stats should no longer go negative when points are deleted. #2054
|
||||||
|
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
|
||||||
|
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.3] - 2025-12-14
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false.
|
- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false.
|
||||||
- In map v2, user can now move points. #2024
|
- In map v2, user can now move points when Points layer is enabled. #2024
|
||||||
- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026
|
- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
|
||||||
1
Gemfile
1
Gemfile
|
|
@ -36,6 +36,7 @@ gem 'puma'
|
||||||
gem 'pundit', '>= 2.5.1'
|
gem 'pundit', '>= 2.5.1'
|
||||||
gem 'rails', '~> 8.0'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'rails_icons'
|
gem 'rails_icons'
|
||||||
|
gem 'rails_pulse'
|
||||||
gem 'redis'
|
gem 'redis'
|
||||||
gem 'rexml'
|
gem 'rexml'
|
||||||
gem 'rgeo'
|
gem 'rgeo'
|
||||||
|
|
|
||||||
60
Gemfile.lock
60
Gemfile.lock
|
|
@ -108,12 +108,12 @@ GEM
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.5.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.0)
|
brakeman (7.1.1)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.2)
|
bundler-audit (0.9.2)
|
||||||
|
|
@ -133,14 +133,15 @@ GEM
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
cronex (0.15.0)
|
cronex (0.15.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
unicode (>= 0.4.4.5)
|
unicode (>= 0.4.4.5)
|
||||||
|
css-zero (1.1.15)
|
||||||
csv (3.3.4)
|
csv (3.3.4)
|
||||||
data_migrate (11.3.1)
|
data_migrate (11.3.1)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
|
|
@ -166,7 +167,7 @@ GEM
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.1.3)
|
erb (6.0.0)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
|
|
@ -208,7 +209,7 @@ GEM
|
||||||
ffi (~> 1.9)
|
ffi (~> 1.9)
|
||||||
rgeo-geojson (~> 2.1)
|
rgeo-geojson (~> 2.1)
|
||||||
zeitwerk (~> 2.5)
|
zeitwerk (~> 2.5)
|
||||||
hashdiff (1.1.2)
|
hashdiff (1.2.1)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
httparty (0.23.1)
|
httparty (0.23.1)
|
||||||
csv
|
csv
|
||||||
|
|
@ -221,7 +222,7 @@ GEM
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.1)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.3)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
|
@ -272,7 +273,7 @@ GEM
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.26.0)
|
minitest (5.26.2)
|
||||||
msgpack (1.7.3)
|
msgpack (1.7.3)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.7.1)
|
multi_xml (0.7.1)
|
||||||
|
|
@ -351,6 +352,9 @@ GEM
|
||||||
optimist (3.2.1)
|
optimist (3.2.1)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
|
pagy (43.2.2)
|
||||||
|
json
|
||||||
|
yaml
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.9.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
|
|
@ -379,14 +383,14 @@ GEM
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.2)
|
pundit (2.5.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.4)
|
||||||
rack-oauth2 (2.3.0)
|
rack-oauth2 (2.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
attr_required
|
attr_required
|
||||||
|
|
@ -429,6 +433,14 @@ GEM
|
||||||
rails_icons (1.4.0)
|
rails_icons (1.4.0)
|
||||||
nokogiri (~> 1.16, >= 1.16.4)
|
nokogiri (~> 1.16, >= 1.16.4)
|
||||||
rails (> 6.1)
|
rails (> 6.1)
|
||||||
|
rails_pulse (0.2.4)
|
||||||
|
css-zero (~> 1.1, >= 1.1.4)
|
||||||
|
groupdate (~> 6.0)
|
||||||
|
pagy (>= 8, < 44)
|
||||||
|
rails (>= 7.1.0, < 9.0.0)
|
||||||
|
ransack (~> 4.0)
|
||||||
|
request_store (~> 1.5)
|
||||||
|
turbo-rails (~> 2.0.11)
|
||||||
railties (8.0.3)
|
railties (8.0.3)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.3)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.3)
|
||||||
|
|
@ -440,16 +452,20 @@ GEM
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rdoc (6.15.0)
|
ransack (4.4.1)
|
||||||
|
activerecord (>= 7.2)
|
||||||
|
activesupport (>= 7.2)
|
||||||
|
i18n
|
||||||
|
rdoc (6.16.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
redis (5.4.0)
|
redis (5.4.1)
|
||||||
redis-client (>= 0.22.0)
|
redis-client (>= 0.22.0)
|
||||||
redis-client (0.24.0)
|
redis-client (0.26.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
|
@ -525,10 +541,10 @@ GEM
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (6.0.0)
|
sentry-rails (6.1.1)
|
||||||
railties (>= 5.2.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 6.0.0)
|
sentry-ruby (~> 6.1.1)
|
||||||
sentry-ruby (6.0.0)
|
sentry-ruby (6.1.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
shoulda-matchers (6.5.0)
|
shoulda-matchers (6.5.0)
|
||||||
|
|
@ -565,7 +581,7 @@ GEM
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.8)
|
||||||
strong_migrations (2.5.1)
|
strong_migrations (2.5.1)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
super_diff (0.17.0)
|
super_diff (0.17.0)
|
||||||
|
|
@ -589,7 +605,7 @@ GEM
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
timeout (0.4.4)
|
timeout (0.4.4)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.17)
|
turbo-rails (2.0.20)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
|
|
@ -598,7 +614,7 @@ GEM
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
activemodel (>= 3.0.0)
|
activemodel (>= 3.0.0)
|
||||||
|
|
@ -610,7 +626,7 @@ GEM
|
||||||
activesupport
|
activesupport
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
webmock (3.25.1)
|
webmock (3.26.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
|
|
@ -625,6 +641,7 @@ GEM
|
||||||
zeitwerk (>= 2.7)
|
zeitwerk (>= 2.7)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
|
yaml (0.4.0)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.7.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
|
|
@ -677,6 +694,7 @@ DEPENDENCIES
|
||||||
pundit (>= 2.5.1)
|
pundit (>= 2.5.1)
|
||||||
rails (~> 8.0)
|
rails (~> 8.0)
|
||||||
rails_icons
|
rails_icons
|
||||||
|
rails_pulse
|
||||||
redis
|
redis
|
||||||
rexml
|
rexml
|
||||||
rgeo
|
rgeo
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1
app/assets/svg/icons/lucide/outline/arrow-big-down.svg
Normal file
1
app/assets/svg/icons/lucide/outline/arrow-big-down.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-arrow-big-down-icon lucide-arrow-big-down"><path d="M15 11a1 1 0 0 0 1 1h2.939a1 1 0 0 1 .75 1.811l-6.835 6.836a1.207 1.207 0 0 1-1.707 0L4.31 13.81a1 1 0 0 1 .75-1.811H8a1 1 0 0 0 1-1V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
1
app/assets/svg/icons/lucide/outline/calendar-plus-2.svg
Normal file
1
app/assets/svg/icons/lucide/outline/calendar-plus-2.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-calendar-plus2-icon lucide-calendar-plus-2"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M10 16h4"/><path d="M12 14v4"/></svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
1
app/assets/svg/icons/lucide/outline/mail.svg
Normal file
1
app/assets/svg/icons/lucide/outline/mail.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-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
|
|
@ -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-message-circle-question-mark-icon lucide-message-circle-question-mark"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
|
|
@ -13,6 +13,7 @@ class Api::V1::PointsController < ApiController
|
||||||
|
|
||||||
points = current_api_user
|
points = current_api_user
|
||||||
.points
|
.points
|
||||||
|
.without_raw_data
|
||||||
.where(timestamp: start_at..end_at)
|
.where(timestamp: start_at..end_at)
|
||||||
|
|
||||||
# Filter by geographic bounds if provided
|
# Filter by geographic bounds if provided
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class ExportsController < ApplicationController
|
||||||
before_action :set_export, only: %i[destroy]
|
before_action :set_export, only: %i[destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
|
@exports = current_user.exports.with_attached_file.order(created_at: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class ImportsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
@imports = policy_scope(Import)
|
@imports = policy_scope(Import)
|
||||||
.select(:id, :name, :source, :created_at, :processed, :status)
|
.select(:id, :name, :source, :created_at, :processed, :status)
|
||||||
|
.with_attached_file
|
||||||
.order(created_at: :desc)
|
.order(created_at: :desc)
|
||||||
.page(params[:page])
|
.page(params[:page])
|
||||||
end
|
end
|
||||||
|
|
@ -78,9 +79,13 @@ class ImportsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
Imports::Destroy.new(current_user, @import).call
|
@import.deleting!
|
||||||
|
Imports::DestroyJob.perform_later(@import.id)
|
||||||
|
|
||||||
redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other
|
respond_to do |format|
|
||||||
|
format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other }
|
||||||
|
format.turbo_stream
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class SettingsController < ApplicationController
|
||||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||||
:visits_suggestions_enabled
|
:visits_suggestions_enabled, :digest_emails_enabled
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
55
app/controllers/shared/digests_controller.rb
Normal file
55
app/controllers/shared/digests_controller.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Shared::DigestsController < ApplicationController
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
before_action :authenticate_user!, except: [:show]
|
||||||
|
before_action :authenticate_active_user!, only: [:update]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@digest = Users::Digest.find_by(sharing_uuid: params[:uuid])
|
||||||
|
|
||||||
|
unless @digest&.public_accessible?
|
||||||
|
return redirect_to root_path,
|
||||||
|
alert: 'Shared digest not found or no longer available'
|
||||||
|
end
|
||||||
|
|
||||||
|
@year = @digest.year
|
||||||
|
@user = @digest.user
|
||||||
|
@distance_unit = @user.safe_settings.distance_unit || 'km'
|
||||||
|
@is_public_view = true
|
||||||
|
|
||||||
|
render 'users/digests/public_year'
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@year = params[:year].to_i
|
||||||
|
@digest = current_user.digests.yearly.find_by(year: @year)
|
||||||
|
|
||||||
|
return head :not_found unless @digest
|
||||||
|
|
||||||
|
if params[:enabled] == '1'
|
||||||
|
@digest.enable_sharing!(expiration: params[:expiration] || '24h')
|
||||||
|
sharing_url = shared_users_digest_url(@digest.sharing_uuid)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
sharing_url: sharing_url,
|
||||||
|
message: 'Sharing enabled successfully'
|
||||||
|
}
|
||||||
|
else
|
||||||
|
@digest.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
|
||||||
53
app/controllers/users/digests_controller.rb
Normal file
53
app/controllers/users/digests_controller.rb
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DigestsController < ApplicationController
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :authenticate_active_user!, only: [:create]
|
||||||
|
before_action :set_digest, only: [:show]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@digests = current_user.digests.yearly.order(year: :desc)
|
||||||
|
@available_years = available_years_for_generation
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@distance_unit = current_user.safe_settings.distance_unit || 'km'
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
year = params[:year].to_i
|
||||||
|
|
||||||
|
if valid_year?(year)
|
||||||
|
Users::Digests::CalculatingJob.perform_later(current_user.id, year)
|
||||||
|
redirect_to users_digests_path,
|
||||||
|
notice: "Year-end digest for #{year} is being generated. Check back soon!",
|
||||||
|
status: :see_other
|
||||||
|
else
|
||||||
|
redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_digest
|
||||||
|
@digest = current_user.digests.yearly.find_by!(year: params[:year])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to users_digests_path, alert: 'Digest not found'
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_years_for_generation
|
||||||
|
tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
|
||||||
|
existing_digests = current_user.digests.yearly.pluck(:year)
|
||||||
|
|
||||||
|
(tracked_years - existing_digests).sort.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_year?(year)
|
||||||
|
return false if year < 2000 || year > Time.current.year
|
||||||
|
|
||||||
|
current_user.stats.exists?(year: year)
|
||||||
|
end
|
||||||
|
end
|
||||||
50
app/helpers/users/digests_helper.rb
Normal file
50
app/helpers/users/digests_helper.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module DigestsHelper
|
||||||
|
def distance_with_unit(distance_meters, unit)
|
||||||
|
value = Users::Digest.convert_distance(distance_meters, unit).round
|
||||||
|
"#{number_with_delimiter(value)} #{unit}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_comparison_text(distance_meters)
|
||||||
|
distance_km = distance_meters.to_f / 1000
|
||||||
|
|
||||||
|
if distance_km >= Users::Digest::MOON_DISTANCE_KM
|
||||||
|
percentage = ((distance_km / Users::Digest::MOON_DISTANCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of the distance to the Moon!"
|
||||||
|
else
|
||||||
|
percentage = ((distance_km / Users::Digest::EARTH_CIRCUMFERENCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of Earth's circumference!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_time_spent(minutes)
|
||||||
|
return "#{minutes} minutes" if minutes < 60
|
||||||
|
|
||||||
|
hours = minutes / 60
|
||||||
|
remaining_minutes = minutes % 60
|
||||||
|
|
||||||
|
if hours < 24
|
||||||
|
"#{hours}h #{remaining_minutes}m"
|
||||||
|
else
|
||||||
|
days = hours / 24
|
||||||
|
remaining_hours = hours % 24
|
||||||
|
"#{days}d #{remaining_hours}h"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_change_class(change)
|
||||||
|
return '' if change.nil?
|
||||||
|
|
||||||
|
change.negative? ? 'negative' : 'positive'
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_change_text(change)
|
||||||
|
return '' if change.nil?
|
||||||
|
|
||||||
|
prefix = change.positive? ? '+' : ''
|
||||||
|
"#{prefix}#{change}%"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,9 +11,57 @@ export default class extends BaseController {
|
||||||
connect() {
|
connect() {
|
||||||
console.log("Datetime controller connected")
|
console.log("Datetime controller connected")
|
||||||
this.debounceTimer = null;
|
this.debounceTimer = null;
|
||||||
|
|
||||||
|
// Add validation listeners
|
||||||
|
if (this.hasStartedAtTarget && this.hasEndedAtTarget) {
|
||||||
|
// Validate on change to set validation state
|
||||||
|
this.startedAtTarget.addEventListener('change', () => this.validateDates())
|
||||||
|
this.endedAtTarget.addEventListener('change', () => this.validateDates())
|
||||||
|
|
||||||
|
// Validate on blur to set validation state
|
||||||
|
this.startedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||||
|
this.endedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||||
|
|
||||||
|
// Add form submit validation
|
||||||
|
const form = this.element.closest('form')
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
if (!this.validateDates()) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.endedAtTarget.reportValidity()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCoordinates(event) {
|
validateDates(showPopup = false) {
|
||||||
|
const startDate = new Date(this.startedAtTarget.value)
|
||||||
|
const endDate = new Date(this.endedAtTarget.value)
|
||||||
|
|
||||||
|
// Clear any existing custom validity
|
||||||
|
this.startedAtTarget.setCustomValidity('')
|
||||||
|
this.endedAtTarget.setCustomValidity('')
|
||||||
|
|
||||||
|
// Check if both dates are valid
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that start date is before end date
|
||||||
|
if (startDate >= endDate) {
|
||||||
|
const errorMessage = 'Start date must be earlier than end date'
|
||||||
|
this.endedAtTarget.setCustomValidity(errorMessage)
|
||||||
|
if (showPopup) {
|
||||||
|
this.endedAtTarget.reportValidity()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCoordinates() {
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer);
|
clearTimeout(this.debounceTimer);
|
||||||
|
|
@ -25,6 +73,11 @@ export default class extends BaseController {
|
||||||
const endedAt = this.endedAtTarget.value
|
const endedAt = this.endedAtTarget.value
|
||||||
const apiKey = this.apiKeyTarget.value
|
const apiKey = this.apiKeyTarget.value
|
||||||
|
|
||||||
|
// Validate dates before making API call (don't show popup, already shown on change)
|
||||||
|
if (!this.validateDates(false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (startedAt && endedAt) {
|
if (startedAt && endedAt) {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,23 @@ export default class extends BaseController {
|
||||||
received: (data) => {
|
received: (data) => {
|
||||||
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
||||||
|
|
||||||
if (row) {
|
if (!row) return;
|
||||||
const pointsCell = row.querySelector('[data-points-count]');
|
|
||||||
if (pointsCell) {
|
|
||||||
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusCell = row.querySelector('[data-status-display]');
|
// Handle deletion complete - remove the row
|
||||||
if (statusCell && data.import.status) {
|
if (data.action === 'delete') {
|
||||||
statusCell.textContent = data.import.status;
|
row.remove();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle status and points updates
|
||||||
|
const pointsCell = row.querySelector('[data-points-count]');
|
||||||
|
if (pointsCell && data.import.points_count !== undefined) {
|
||||||
|
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCell = row.querySelector('[data-status-display]');
|
||||||
|
if (statusCell && data.import.status) {
|
||||||
|
statusCell.textContent = data.import.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ export class LayerManager {
|
||||||
// Always create fog layer for backward compatibility
|
// Always create fog layer for backward compatibility
|
||||||
if (!this.layers.fogLayer) {
|
if (!this.layers.fogLayer) {
|
||||||
this.layers.fogLayer = new FogLayer(this.map, {
|
this.layers.fogLayer = new FogLayer(this.map, {
|
||||||
clearRadius: 1000,
|
clearRadius: this.settings.fogOfWarRadius || 1000,
|
||||||
visible: this.settings.fogEnabled || false
|
visible: this.settings.fogEnabled || false
|
||||||
})
|
})
|
||||||
this.layers.fogLayer.add(pointsGeoJSON)
|
this.layers.fogLayer.add(pointsGeoJSON)
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export class RoutesManager {
|
||||||
timestamp: f.properties.timestamp
|
timestamp: f.properties.timestamp
|
||||||
})) || []
|
})) || []
|
||||||
|
|
||||||
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500
|
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 1000
|
||||||
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
|
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
|
||||||
|
|
||||||
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
|
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
|
||||||
|
|
@ -357,4 +357,28 @@ export class RoutesManager {
|
||||||
|
|
||||||
SettingsManager.updateSetting('pointsVisible', visible)
|
SettingsManager.updateSetting('pointsVisible', visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle family members layer
|
||||||
|
*/
|
||||||
|
async toggleFamily(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('familyEnabled', enabled)
|
||||||
|
|
||||||
|
const familyLayer = this.layerManager.getLayer('family')
|
||||||
|
if (familyLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
familyLayer.show()
|
||||||
|
// Load family members data
|
||||||
|
await this.controller.loadFamilyMembers()
|
||||||
|
} else {
|
||||||
|
familyLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the family members list
|
||||||
|
if (this.controller.hasFamilyMembersListTarget) {
|
||||||
|
this.controller.familyMembersListTarget.style.display = enabled ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,14 @@ export class SettingsController {
|
||||||
placesToggle: 'placesEnabled',
|
placesToggle: 'placesEnabled',
|
||||||
fogToggle: 'fogEnabled',
|
fogToggle: 'fogEnabled',
|
||||||
scratchToggle: 'scratchEnabled',
|
scratchToggle: 'scratchEnabled',
|
||||||
|
familyToggle: 'familyEnabled',
|
||||||
speedColoredToggle: 'speedColoredRoutesEnabled'
|
speedColoredToggle: 'speedColoredRoutesEnabled'
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
||||||
const target = `${targetName}Target`
|
const target = `${targetName}Target`
|
||||||
if (controller[target]) {
|
const hasTarget = `has${targetName.charAt(0).toUpperCase()}${targetName.slice(1)}Target`
|
||||||
|
if (controller[hasTarget]) {
|
||||||
controller[target].checked = this.settings[settingKey]
|
controller[target].checked = this.settings[settingKey]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -73,6 +75,11 @@ export class SettingsController {
|
||||||
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide family members list based on initial toggle state
|
||||||
|
if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget && controller.familyToggleTarget) {
|
||||||
|
controller.familyMembersListTarget.style.display = controller.familyToggleTarget.checked ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
// Sync route opacity slider
|
// Sync route opacity slider
|
||||||
if (controller.hasRouteOpacityRangeTarget) {
|
if (controller.hasRouteOpacityRangeTarget) {
|
||||||
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||||
|
|
@ -238,8 +245,8 @@ export class SettingsController {
|
||||||
if (settings.fogOfWarRadius) {
|
if (settings.fogOfWarRadius) {
|
||||||
fogLayer.clearRadius = settings.fogOfWarRadius
|
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||||
}
|
}
|
||||||
// Redraw fog layer
|
// Redraw fog layer if it has data and is visible
|
||||||
if (fogLayer.visible) {
|
if (fogLayer.visible && fogLayer.data) {
|
||||||
await fogLayer.update(fogLayer.data)
|
await fogLayer.update(fogLayer.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,15 @@ export default class extends Controller {
|
||||||
'placesToggle',
|
'placesToggle',
|
||||||
'fogToggle',
|
'fogToggle',
|
||||||
'scratchToggle',
|
'scratchToggle',
|
||||||
|
'familyToggle',
|
||||||
// Speed-colored routes
|
// Speed-colored routes
|
||||||
'routesOptions',
|
'routesOptions',
|
||||||
'speedColoredToggle',
|
'speedColoredToggle',
|
||||||
'speedColorScaleContainer',
|
'speedColorScaleContainer',
|
||||||
'speedColorScaleInput',
|
'speedColorScaleInput',
|
||||||
|
// Family members
|
||||||
|
'familyMembersList',
|
||||||
|
'familyMembersContainer',
|
||||||
// Area selection
|
// Area selection
|
||||||
'selectAreaButton',
|
'selectAreaButton',
|
||||||
'selectionActions',
|
'selectionActions',
|
||||||
|
|
@ -347,6 +351,103 @@ export default class extends Controller {
|
||||||
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
||||||
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
||||||
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
|
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
|
||||||
|
toggleFamily(event) { return this.routesManager.toggleFamily(event) }
|
||||||
|
|
||||||
|
// Family Members methods
|
||||||
|
async loadFamilyMembers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${this.apiKeyValue}`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
console.warn('[Maps V2] Family feature not enabled or user not in family')
|
||||||
|
Toast.info('Family feature not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const locations = data.locations || []
|
||||||
|
|
||||||
|
// Update family layer with locations
|
||||||
|
const familyLayer = this.layerManager.getLayer('family')
|
||||||
|
if (familyLayer) {
|
||||||
|
familyLayer.loadMembers(locations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render family members list
|
||||||
|
this.renderFamilyMembersList(locations)
|
||||||
|
|
||||||
|
Toast.success(`Loaded ${locations.length} family member(s)`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to load family members:', error)
|
||||||
|
Toast.error('Failed to load family members')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFamilyMembersList(locations) {
|
||||||
|
if (!this.hasFamilyMembersContainerTarget) return
|
||||||
|
|
||||||
|
const container = this.familyMembersContainerTarget
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-xs text-base-content/60">No family members sharing location</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = locations.map(location => {
|
||||||
|
const emailInitial = location.email?.charAt(0)?.toUpperCase() || '?'
|
||||||
|
const color = this.getFamilyMemberColor(location.user_id)
|
||||||
|
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', {
|
||||||
|
timeZone: this.timezoneValue || 'UTC',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex items-center gap-2 p-2 hover:bg-base-200 rounded-lg cursor-pointer transition-colors"
|
||||||
|
data-action="click->maps--maplibre#centerOnFamilyMember"
|
||||||
|
data-member-id="${location.user_id}">
|
||||||
|
<div style="background-color: ${color}; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; flex-shrink: 0;">
|
||||||
|
${emailInitial}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate">${location.email || 'Unknown'}</div>
|
||||||
|
<div class="text-xs text-base-content/60">${lastSeen}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
getFamilyMemberColor(userId) {
|
||||||
|
const colors = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b',
|
||||||
|
'#ef4444', '#8b5cf6', '#ec4899'
|
||||||
|
]
|
||||||
|
// Use user ID to get consistent color
|
||||||
|
const hash = userId.toString().split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||||
|
return colors[hash % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
centerOnFamilyMember(event) {
|
||||||
|
const memberId = event.currentTarget.dataset.memberId
|
||||||
|
if (!memberId) return
|
||||||
|
|
||||||
|
const familyLayer = this.layerManager.getLayer('family')
|
||||||
|
if (familyLayer) {
|
||||||
|
familyLayer.centerOnMember(parseInt(memberId))
|
||||||
|
Toast.success('Centered on family member')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Info Display methods
|
// Info Display methods
|
||||||
showInfo(title, content, actions = []) {
|
showInfo(title, content, actions = []) {
|
||||||
|
|
|
||||||
|
|
@ -148,4 +148,63 @@ export class FamilyLayer extends BaseLayer {
|
||||||
features: filtered
|
features: filtered
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all family members from API
|
||||||
|
* @param {Object} locations - Array of family member locations
|
||||||
|
*/
|
||||||
|
loadMembers(locations) {
|
||||||
|
if (!Array.isArray(locations)) {
|
||||||
|
console.warn('[FamilyLayer] Invalid locations data:', locations)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = locations.map(location => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [location.longitude, location.latitude]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: location.user_id,
|
||||||
|
name: location.email || 'Unknown',
|
||||||
|
email: location.email,
|
||||||
|
color: location.color || this.getMemberColor(location.user_id),
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
battery: location.battery,
|
||||||
|
batteryStatus: location.battery_status,
|
||||||
|
updatedAt: location.updated_at
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.update({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center map on specific family member
|
||||||
|
* @param {string} memberId - ID of the member to center on
|
||||||
|
*/
|
||||||
|
centerOnMember(memberId) {
|
||||||
|
const features = this.data?.features || []
|
||||||
|
const member = features.find(f => f.properties.id === memberId)
|
||||||
|
|
||||||
|
if (member && this.map) {
|
||||||
|
this.map.flyTo({
|
||||||
|
center: member.geometry.coordinates,
|
||||||
|
zoom: 15,
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all current family members
|
||||||
|
* @returns {Array} Array of member features
|
||||||
|
*/
|
||||||
|
getMembers() {
|
||||||
|
return this.data?.features || []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ export class FogLayer {
|
||||||
this.ctx = null
|
this.ctx = null
|
||||||
this.clearRadius = options.clearRadius || 1000 // meters
|
this.clearRadius = options.clearRadius || 1000 // meters
|
||||||
this.points = []
|
this.points = []
|
||||||
|
this.data = null // Store original data for updates
|
||||||
}
|
}
|
||||||
|
|
||||||
add(data) {
|
add(data) {
|
||||||
|
this.data = data // Store for later updates
|
||||||
this.points = data.features || []
|
this.points = data.features || []
|
||||||
this.createCanvas()
|
this.createCanvas()
|
||||||
if (this.visible) {
|
if (this.visible) {
|
||||||
|
|
@ -24,6 +26,7 @@ export class FogLayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
update(data) {
|
update(data) {
|
||||||
|
this.data = data // Store for later updates
|
||||||
this.points = data.features || []
|
this.points = data.features || []
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +81,7 @@ export class FogLayer {
|
||||||
|
|
||||||
// Clear circles around visited points
|
// Clear circles around visited points
|
||||||
this.ctx.globalCompositeOperation = 'destination-out'
|
this.ctx.globalCompositeOperation = 'destination-out'
|
||||||
|
this.ctx.fillStyle = 'rgba(0, 0, 0, 1)' // Fully opaque to completely clear fog
|
||||||
|
|
||||||
this.points.forEach(feature => {
|
this.points.forEach(feature => {
|
||||||
const coords = feature.geometry.coordinates
|
const coords = feature.geometry.coordinates
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,10 @@ import { BaseLayer } from './base_layer'
|
||||||
/**
|
/**
|
||||||
* Heatmap layer showing point density
|
* Heatmap layer showing point density
|
||||||
* Uses MapLibre's native heatmap for performance
|
* Uses MapLibre's native heatmap for performance
|
||||||
* Fixed radius: 20 pixels
|
|
||||||
*/
|
*/
|
||||||
export class HeatmapLayer extends BaseLayer {
|
export class HeatmapLayer extends BaseLayer {
|
||||||
constructor(map, options = {}) {
|
constructor(map, options = {}) {
|
||||||
super(map, { id: 'heatmap', ...options })
|
super(map, { id: 'heatmap', ...options })
|
||||||
this.radius = 20 // Fixed radius
|
|
||||||
this.weight = options.weight || 1
|
|
||||||
this.intensity = 1 // Fixed intensity
|
|
||||||
this.opacity = options.opacity || 0.6
|
this.opacity = options.opacity || 0.6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,53 +27,52 @@ export class HeatmapLayer extends BaseLayer {
|
||||||
type: 'heatmap',
|
type: 'heatmap',
|
||||||
source: this.sourceId,
|
source: this.sourceId,
|
||||||
paint: {
|
paint: {
|
||||||
// Increase weight as diameter increases
|
// Fixed weight
|
||||||
'heatmap-weight': [
|
'heatmap-weight': 1,
|
||||||
'interpolate',
|
|
||||||
['linear'],
|
|
||||||
['get', 'weight'],
|
|
||||||
0, 0,
|
|
||||||
6, 1
|
|
||||||
],
|
|
||||||
|
|
||||||
// Increase intensity as zoom increases
|
// low intensity to view major clusters
|
||||||
'heatmap-intensity': [
|
'heatmap-intensity': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
0, this.intensity,
|
0, 0.01,
|
||||||
9, this.intensity * 3
|
10, 0.1,
|
||||||
|
15, 0.3
|
||||||
],
|
],
|
||||||
|
|
||||||
// Color ramp from blue to red
|
// Color ramp
|
||||||
'heatmap-color': [
|
'heatmap-color': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['heatmap-density'],
|
['heatmap-density'],
|
||||||
0, 'rgba(33,102,172,0)',
|
0, 'rgba(0,0,0,0)',
|
||||||
0.2, 'rgb(103,169,207)',
|
0.4, 'rgba(0,0,0,0)',
|
||||||
0.4, 'rgb(209,229,240)',
|
0.65, 'rgba(33,102,172,0.4)',
|
||||||
0.6, 'rgb(253,219,199)',
|
0.7, 'rgb(103,169,207)',
|
||||||
0.8, 'rgb(239,138,98)',
|
0.8, 'rgb(209,229,240)',
|
||||||
|
0.9, 'rgb(253,219,199)',
|
||||||
|
0.95, 'rgb(239,138,98)',
|
||||||
1, 'rgb(178,24,43)'
|
1, 'rgb(178,24,43)'
|
||||||
],
|
],
|
||||||
|
|
||||||
// Fixed radius adjusted by zoom level
|
// Radius in pixels, exponential growth
|
||||||
'heatmap-radius': [
|
'heatmap-radius': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['exponential', 2],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
0, this.radius,
|
10, 5,
|
||||||
9, this.radius * 3
|
15, 10,
|
||||||
|
20, 160
|
||||||
],
|
],
|
||||||
|
|
||||||
// Transition from heatmap to circle layer by zoom level
|
// Visible when zoomed in, fades when zoomed out
|
||||||
'heatmap-opacity': [
|
'heatmap-opacity': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
7, this.opacity,
|
0, 0.3,
|
||||||
9, 0
|
10, this.opacity,
|
||||||
|
15, this.opacity
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { BaseLayer } from './base_layer'
|
import { BaseLayer } from './base_layer'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Points layer for displaying individual location points
|
* Points layer for displaying individual location points
|
||||||
|
|
@ -12,6 +13,13 @@ export class PointsLayer extends BaseLayer {
|
||||||
this.isDragging = false
|
this.isDragging = false
|
||||||
this.draggedFeature = null
|
this.draggedFeature = null
|
||||||
this.canvas = null
|
this.canvas = null
|
||||||
|
|
||||||
|
// Bind event handlers once and store references for proper cleanup
|
||||||
|
this._onMouseEnter = this.onMouseEnter.bind(this)
|
||||||
|
this._onMouseLeave = this.onMouseLeave.bind(this)
|
||||||
|
this._onMouseDown = this.onMouseDown.bind(this)
|
||||||
|
this._onMouseMove = this.onMouseMove.bind(this)
|
||||||
|
this._onMouseUp = this.onMouseUp.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
getSourceConfig() {
|
getSourceConfig() {
|
||||||
|
|
@ -51,11 +59,11 @@ export class PointsLayer extends BaseLayer {
|
||||||
this.canvas = this.map.getCanvasContainer()
|
this.canvas = this.map.getCanvasContainer()
|
||||||
|
|
||||||
// Change cursor to pointer when hovering over points
|
// Change cursor to pointer when hovering over points
|
||||||
this.map.on('mouseenter', this.id, this.onMouseEnter.bind(this))
|
this.map.on('mouseenter', this.id, this._onMouseEnter)
|
||||||
this.map.on('mouseleave', this.id, this.onMouseLeave.bind(this))
|
this.map.on('mouseleave', this.id, this._onMouseLeave)
|
||||||
|
|
||||||
// Handle drag events
|
// Handle drag events
|
||||||
this.map.on('mousedown', this.id, this.onMouseDown.bind(this))
|
this.map.on('mousedown', this.id, this._onMouseDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,9 +74,9 @@ export class PointsLayer extends BaseLayer {
|
||||||
|
|
||||||
this.draggingEnabled = false
|
this.draggingEnabled = false
|
||||||
|
|
||||||
this.map.off('mouseenter', this.id, this.onMouseEnter.bind(this))
|
this.map.off('mouseenter', this.id, this._onMouseEnter)
|
||||||
this.map.off('mouseleave', this.id, this.onMouseLeave.bind(this))
|
this.map.off('mouseleave', this.id, this._onMouseLeave)
|
||||||
this.map.off('mousedown', this.id, this.onMouseDown.bind(this))
|
this.map.off('mousedown', this.id, this._onMouseDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseEnter() {
|
onMouseEnter() {
|
||||||
|
|
@ -91,8 +99,8 @@ export class PointsLayer extends BaseLayer {
|
||||||
this.canvas.style.cursor = 'grabbing'
|
this.canvas.style.cursor = 'grabbing'
|
||||||
|
|
||||||
// Bind mouse move and up events
|
// Bind mouse move and up events
|
||||||
this.map.on('mousemove', this.onMouseMove.bind(this))
|
this.map.on('mousemove', this._onMouseMove)
|
||||||
this.map.once('mouseup', this.onMouseUp.bind(this))
|
this.map.once('mouseup', this._onMouseUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseMove(e) {
|
onMouseMove(e) {
|
||||||
|
|
@ -123,7 +131,7 @@ export class PointsLayer extends BaseLayer {
|
||||||
// Clean up drag state
|
// Clean up drag state
|
||||||
this.isDragging = false
|
this.isDragging = false
|
||||||
this.canvas.style.cursor = ''
|
this.canvas.style.cursor = ''
|
||||||
this.map.off('mousemove', this.onMouseMove.bind(this))
|
this.map.off('mousemove', this._onMouseMove)
|
||||||
|
|
||||||
// Update the point on the backend
|
// Update the point on the backend
|
||||||
try {
|
try {
|
||||||
|
|
@ -143,7 +151,7 @@ export class PointsLayer extends BaseLayer {
|
||||||
source.setData(data)
|
source.setData(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alert('Failed to update point position. Please try again.')
|
Toast.error('Failed to update point position. Please try again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.draggedFeature = null
|
this.draggedFeature = null
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
|
||||||
|
|
||||||
users.active.find_each do |user|
|
users.active.find_each do |user|
|
||||||
next unless user.safe_settings.visits_suggestions_enabled?
|
next unless user.safe_settings.visits_suggestions_enabled?
|
||||||
next unless user.points_count.positive?
|
next unless user.points_count&.positive?
|
||||||
|
|
||||||
schedule_chunked_jobs(user, time_chunks)
|
schedule_chunked_jobs(user, time_chunks)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
46
app/jobs/imports/destroy_job.rb
Normal file
46
app/jobs/imports/destroy_job.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Imports::DestroyJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(import_id)
|
||||||
|
import = Import.find_by(id: import_id)
|
||||||
|
return unless import
|
||||||
|
|
||||||
|
import.deleting!
|
||||||
|
broadcast_status_update(import)
|
||||||
|
|
||||||
|
Imports::Destroy.new(import.user, import).call
|
||||||
|
|
||||||
|
broadcast_deletion_complete(import)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
Rails.logger.warn "Import #{import_id} not found, may have already been deleted"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def broadcast_status_update(import)
|
||||||
|
ImportsChannel.broadcast_to(
|
||||||
|
import.user,
|
||||||
|
{
|
||||||
|
action: 'status_update',
|
||||||
|
import: {
|
||||||
|
id: import.id,
|
||||||
|
status: import.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_deletion_complete(import)
|
||||||
|
ImportsChannel.broadcast_to(
|
||||||
|
import.user,
|
||||||
|
{
|
||||||
|
action: 'delete',
|
||||||
|
import: {
|
||||||
|
id: import.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,8 +6,15 @@ class Points::NightlyReverseGeocodingJob < ApplicationJob
|
||||||
def perform
|
def perform
|
||||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||||
|
|
||||||
|
processed_user_ids = Set.new
|
||||||
|
|
||||||
Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point|
|
Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point|
|
||||||
point.async_reverse_geocode
|
point.async_reverse_geocode
|
||||||
|
processed_user_ids.add(point.user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
processed_user_ids.each do |user_id|
|
||||||
|
Cache::InvalidateUserCaches.new(user_id).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class Tracks::DailyGenerationJob < ApplicationJob
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
User.active_or_trial.find_each do |user|
|
User.active_or_trial.find_each do |user|
|
||||||
next if user.points_count.zero?
|
next if user.points_count&.zero?
|
||||||
|
|
||||||
process_user_daily_tracks(user)
|
process_user_daily_tracks(user)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
|
|
||||||
26
app/jobs/users/digests/calculating_job.rb
Normal file
26
app/jobs/users/digests/calculating_job.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digests::CalculatingJob < ApplicationJob
|
||||||
|
queue_as :digests
|
||||||
|
|
||||||
|
def perform(user_id, year)
|
||||||
|
Users::Digests::CalculateYear.new(user_id, year).call
|
||||||
|
rescue StandardError => e
|
||||||
|
create_digest_failed_notification(user_id, e)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_digest_failed_notification(user_id, error)
|
||||||
|
user = User.find(user_id)
|
||||||
|
|
||||||
|
Notifications::Create.new(
|
||||||
|
user:,
|
||||||
|
kind: :error,
|
||||||
|
title: 'Year-End Digest calculation failed',
|
||||||
|
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||||
|
).call
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
31
app/jobs/users/digests/email_sending_job.rb
Normal file
31
app/jobs/users/digests/email_sending_job.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digests::EmailSendingJob < ApplicationJob
|
||||||
|
queue_as :mailers
|
||||||
|
|
||||||
|
def perform(user_id, year)
|
||||||
|
user = User.find(user_id)
|
||||||
|
digest = user.digests.yearly.find_by(year: year)
|
||||||
|
|
||||||
|
return unless should_send_email?(user, digest)
|
||||||
|
|
||||||
|
Users::DigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later
|
||||||
|
|
||||||
|
digest.update!(sent_at: Time.current)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
ExceptionReporter.call(
|
||||||
|
'Users::Digests::EmailSendingJob',
|
||||||
|
"User with ID #{user_id} not found. Skipping year-end digest email."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def should_send_email?(user, digest)
|
||||||
|
return false unless user.safe_settings.digest_emails_enabled?
|
||||||
|
return false if digest.blank?
|
||||||
|
return false if digest.sent_at.present?
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
20
app/jobs/users/digests/year_end_scheduling_job.rb
Normal file
20
app/jobs/users/digests/year_end_scheduling_job.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digests::YearEndSchedulingJob < ApplicationJob
|
||||||
|
queue_as :digests
|
||||||
|
|
||||||
|
def perform
|
||||||
|
year = Time.current.year - 1 # Previous year's digest
|
||||||
|
|
||||||
|
::User.active_or_trial.find_each do |user|
|
||||||
|
# Skip if user has no data for the year
|
||||||
|
next unless user.stats.where(year: year).exists?
|
||||||
|
|
||||||
|
# Schedule calculation first
|
||||||
|
Users::Digests::CalculatingJob.perform_later(user.id, year)
|
||||||
|
|
||||||
|
# Schedule email with delay to allow calculation to complete
|
||||||
|
Users::Digests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
17
app/mailers/users/digests_mailer.rb
Normal file
17
app/mailers/users/digests_mailer.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DigestsMailer < ApplicationMailer
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
def year_end_digest
|
||||||
|
@user = params[:user]
|
||||||
|
@digest = params[:digest]
|
||||||
|
@distance_unit = @user.safe_settings.distance_unit || 'km'
|
||||||
|
|
||||||
|
mail(
|
||||||
|
to: @user.email,
|
||||||
|
subject: "Your #{@digest.year} Year in Review - Dawarich"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -60,17 +60,19 @@ module Archivable
|
||||||
io = StringIO.new(compressed_content)
|
io = StringIO.new(compressed_content)
|
||||||
gz = Zlib::GzipReader.new(io)
|
gz = Zlib::GzipReader.new(io)
|
||||||
|
|
||||||
result = nil
|
begin
|
||||||
gz.each_line do |line|
|
result = nil
|
||||||
data = JSON.parse(line)
|
gz.each_line do |line|
|
||||||
if data['id'] == id
|
data = JSON.parse(line)
|
||||||
result = data['raw_data']
|
if data['id'] == id
|
||||||
break
|
result = data['raw_data']
|
||||||
|
break
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
result || {}
|
||||||
|
ensure
|
||||||
|
gz.close
|
||||||
end
|
end
|
||||||
|
|
||||||
gz.close
|
|
||||||
result || {}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_archive_fetch_error(error)
|
def handle_archive_fetch_error(error)
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,18 @@ module Taggable
|
||||||
has_many :tags, through: :taggings
|
has_many :tags, through: :taggings
|
||||||
|
|
||||||
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
|
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
|
||||||
scope :with_all_tags, ->(tag_ids) {
|
scope :with_all_tags, lambda { |tag_ids|
|
||||||
tag_ids = Array(tag_ids)
|
tag_ids = Array(tag_ids).uniq
|
||||||
return none if tag_ids.empty?
|
return none if tag_ids.empty?
|
||||||
|
|
||||||
# For each tag, join and filter, then use HAVING to ensure all tags are present
|
# For each tag, join and filter, then use HAVING to ensure all tags are present
|
||||||
joins(:taggings)
|
joins(:taggings)
|
||||||
.where(taggings: { tag_id: tag_ids })
|
.where(taggings: { tag_id: tag_ids })
|
||||||
.group("#{table_name}.id")
|
.group("#{table_name}.id")
|
||||||
.having("COUNT(DISTINCT taggings.tag_id) = ?", tag_ids.length)
|
.having('COUNT(DISTINCT taggings.tag_id) = ?', tag_ids.length)
|
||||||
}
|
}
|
||||||
scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
|
scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
|
||||||
scope :tagged_with, ->(tag_name, user) {
|
scope :tagged_with, lambda { |tag_name, user|
|
||||||
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
|
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class Import < ApplicationRecord
|
||||||
validate :file_size_within_limit, if: -> { user.trial? }
|
validate :file_size_within_limit, if: -> { user.trial? }
|
||||||
validate :import_count_within_limit, if: -> { user.trial? }
|
validate :import_count_within_limit, if: -> { user.trial? }
|
||||||
|
|
||||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
enum :status, { created: 0, processing: 1, completed: 2, failed: 3, deleting: 4 }
|
||||||
|
|
||||||
enum :source, {
|
enum :source, {
|
||||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,14 @@ class Stat < ApplicationRecord
|
||||||
|
|
||||||
def enable_sharing!(expiration: '1h')
|
def enable_sharing!(expiration: '1h')
|
||||||
# Default to 24h if an invalid expiration is provided
|
# Default to 24h if an invalid expiration is provided
|
||||||
expiration = '24h' unless %w[1h 12h 24h].include?(expiration)
|
expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration)
|
||||||
|
|
||||||
expires_at = case expiration
|
expires_at = case expiration
|
||||||
when '1h' then 1.hour.from_now
|
when '1h' then 1.hour.from_now
|
||||||
when '12h' then 12.hours.from_now
|
when '12h' then 12.hours.from_now
|
||||||
when '24h' then 24.hours.from_now
|
when '24h' then 24.hours.from_now
|
||||||
|
when '1w' then 1.week.from_now
|
||||||
|
when '1m' then 1.month.from_now
|
||||||
end
|
end
|
||||||
|
|
||||||
update!(
|
update!(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class Trip < ApplicationRecord
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
validates :name, :started_at, :ended_at, presence: true
|
validates :name, :started_at, :ended_at, presence: true
|
||||||
|
validate :started_at_before_ended_at
|
||||||
|
|
||||||
after_create :enqueue_calculation_jobs
|
after_create :enqueue_calculation_jobs
|
||||||
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
|
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
|
||||||
|
|
@ -47,4 +48,11 @@ class Trip < ApplicationRecord
|
||||||
# to show all photos in the same height
|
# to show all photos in the same height
|
||||||
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def started_at_before_ended_at
|
||||||
|
return if started_at.blank? || ended_at.blank?
|
||||||
|
return unless started_at >= ended_at
|
||||||
|
|
||||||
|
errors.add(:ended_at, 'must be after start date')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
has_many :trips, dependent: :destroy
|
has_many :trips, dependent: :destroy
|
||||||
has_many :tracks, dependent: :destroy
|
has_many :tracks, dependent: :destroy
|
||||||
has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy
|
has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy
|
||||||
|
has_many :digests, class_name: 'Users::Digest', dependent: :destroy
|
||||||
|
|
||||||
after_create :create_api_key
|
after_create :create_api_key
|
||||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||||
|
|
@ -73,7 +74,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_reverse_geocoded_points
|
def total_reverse_geocoded_points
|
||||||
points.where.not(reverse_geocoded_at: nil).count
|
StatsQuery.new(self).points_stats[:geocoded]
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_reverse_geocoded_points_without_data
|
def total_reverse_geocoded_points_without_data
|
||||||
|
|
|
||||||
154
app/models/users/digest.rb
Normal file
154
app/models/users/digest.rb
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::Digest < ApplicationRecord
|
||||||
|
self.table_name = 'digests'
|
||||||
|
|
||||||
|
include DistanceConvertible
|
||||||
|
|
||||||
|
EARTH_CIRCUMFERENCE_KM = 40_075
|
||||||
|
MOON_DISTANCE_KM = 384_400
|
||||||
|
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
validates :year, :period_type, presence: true
|
||||||
|
validates :year, uniqueness: { scope: %i[user_id period_type] }
|
||||||
|
|
||||||
|
before_create :generate_sharing_uuid
|
||||||
|
|
||||||
|
enum :period_type, { monthly: 0, yearly: 1 }
|
||||||
|
|
||||||
|
def sharing_enabled?
|
||||||
|
sharing_settings.try(:[], 'enabled') == true
|
||||||
|
end
|
||||||
|
|
||||||
|
def sharing_expired?
|
||||||
|
expiration = sharing_settings.try(:[], 'expiration')
|
||||||
|
return false if expiration.blank?
|
||||||
|
|
||||||
|
expires_at_value = sharing_settings.try(:[], '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: '24h')
|
||||||
|
expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration)
|
||||||
|
|
||||||
|
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
|
||||||
|
when '1w' then 1.week.from_now
|
||||||
|
when '1m' then 1.month.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 countries_count
|
||||||
|
return 0 unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.count { |t| t['country'].present? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def cities_count
|
||||||
|
return 0 unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.sum { |t| t['cities']&.count || 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_time_countries
|
||||||
|
first_time_visits['countries'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_time_cities
|
||||||
|
first_time_visits['cities'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def top_countries_by_time
|
||||||
|
time_spent_by_location['countries'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def top_cities_by_time
|
||||||
|
time_spent_by_location['cities'] || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_distance_change
|
||||||
|
year_over_year['distance_change_percent']
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_countries_change
|
||||||
|
year_over_year['countries_change']
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_cities_change
|
||||||
|
year_over_year['cities_change']
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_year
|
||||||
|
year_over_year['previous_year']
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_countries_all_time
|
||||||
|
all_time_stats['total_countries'] || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_cities_all_time
|
||||||
|
all_time_stats['total_cities'] || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_distance_all_time
|
||||||
|
(all_time_stats['total_distance'] || 0).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_km
|
||||||
|
distance.to_f / 1000
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_comparison_text
|
||||||
|
if distance_km >= MOON_DISTANCE_KM
|
||||||
|
percentage = ((distance_km / MOON_DISTANCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of the distance to the Moon!"
|
||||||
|
else
|
||||||
|
percentage = ((distance_km / EARTH_CIRCUMFERENCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of Earth's circumference!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_sharing_uuid
|
||||||
|
self.sharing_uuid ||= SecureRandom.uuid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,7 +11,7 @@ class StatsQuery
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
total: user.points_count,
|
total: user.points_count.to_i,
|
||||||
geocoded: cached_stats[:geocoded],
|
geocoded: cached_stats[:geocoded],
|
||||||
without_data: cached_stats[:without_data]
|
without_data: cached_stats[:without_data]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class StatsSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def reverse_geocoded_points
|
def reverse_geocoded_points
|
||||||
user.points.reverse_geocoded.count
|
StatsQuery.new(user).points_stats[:geocoded]
|
||||||
end
|
end
|
||||||
|
|
||||||
def yearly_stats
|
def yearly_stats
|
||||||
|
|
|
||||||
4
app/services/cache/clean.rb
vendored
4
app/services/cache/clean.rb
vendored
|
|
@ -36,8 +36,8 @@ class Cache::Clean
|
||||||
|
|
||||||
def delete_countries_cities_cache
|
def delete_countries_cities_cache
|
||||||
User.find_each do |user|
|
User.find_each do |user|
|
||||||
Rails.cache.delete("dawarich/user_#{user.id}_countries")
|
Rails.cache.delete("dawarich/user_#{user.id}_countries_visited")
|
||||||
Rails.cache.delete("dawarich/user_#{user.id}_cities")
|
Rails.cache.delete("dawarich/user_#{user.id}_cities_visited")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
34
app/services/cache/invalidate_user_caches.rb
vendored
Normal file
34
app/services/cache/invalidate_user_caches.rb
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Cache::InvalidateUserCaches
|
||||||
|
# Invalidates user-specific caches that depend on point data.
|
||||||
|
# This should be called after:
|
||||||
|
# - Reverse geocoding operations (updates country/city data)
|
||||||
|
# - Stats calculations (updates geocoding stats)
|
||||||
|
# - Bulk point imports/updates
|
||||||
|
def initialize(user_id)
|
||||||
|
@user_id = user_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
invalidate_countries_visited
|
||||||
|
invalidate_cities_visited
|
||||||
|
invalidate_points_geocoded_stats
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalidate_countries_visited
|
||||||
|
Rails.cache.delete("dawarich/user_#{user_id}_countries_visited")
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalidate_cities_visited
|
||||||
|
Rails.cache.delete("dawarich/user_#{user_id}_cities_visited")
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalidate_points_geocoded_stats
|
||||||
|
Rails.cache.delete("dawarich/user_#{user_id}_points_geocoded_stats")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user_id
|
||||||
|
end
|
||||||
|
|
@ -10,8 +10,8 @@ class CountriesAndCities
|
||||||
|
|
||||||
def call
|
def call
|
||||||
points
|
points
|
||||||
.reject { |point| point.country_name.nil? || point.city.nil? }
|
.reject { |point| point[:country_name].nil? || point[:city].nil? }
|
||||||
.group_by(&:country_name)
|
.group_by { |point| point[:country_name] }
|
||||||
.transform_values { |country_points| process_country_points(country_points) }
|
.transform_values { |country_points| process_country_points(country_points) }
|
||||||
.map { |country, cities| CountryData.new(country: country, cities: cities) }
|
.map { |country, cities| CountryData.new(country: country, cities: cities) }
|
||||||
end
|
end
|
||||||
|
|
@ -22,7 +22,7 @@ class CountriesAndCities
|
||||||
|
|
||||||
def process_country_points(country_points)
|
def process_country_points(country_points)
|
||||||
country_points
|
country_points
|
||||||
.group_by(&:city)
|
.group_by { |point| point[:city] }
|
||||||
.transform_values { |city_points| create_city_data_if_valid(city_points) }
|
.transform_values { |city_points| create_city_data_if_valid(city_points) }
|
||||||
.values
|
.values
|
||||||
.compact
|
.compact
|
||||||
|
|
@ -31,7 +31,7 @@ class CountriesAndCities
|
||||||
def create_city_data_if_valid(city_points)
|
def create_city_data_if_valid(city_points)
|
||||||
timestamps = city_points.pluck(:timestamp)
|
timestamps = city_points.pluck(:timestamp)
|
||||||
duration = calculate_duration_in_minutes(timestamps)
|
duration = calculate_duration_in_minutes(timestamps)
|
||||||
city = city_points.first.city
|
city = city_points.first[:city]
|
||||||
points_count = city_points.size
|
points_count = city_points.size
|
||||||
|
|
||||||
build_city_data(city, points_count, timestamps, duration)
|
build_city_data(city, points_count, timestamps, duration)
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,15 @@ class Imports::Destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
|
points_count = @import.points_count.to_i
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@import.points.delete_all
|
@import.points.destroy_all
|
||||||
@import.destroy!
|
@import.destroy!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Import #{@import.id} deleted with #{points_count} points"
|
||||||
|
|
||||||
Stats::BulkCalculator.new(@user.id).call
|
Stats::BulkCalculator.new(@user.id).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ class Points::Create
|
||||||
def call
|
def call
|
||||||
data = Points::Params.new(params, user.id).call
|
data = Points::Params.new(params, user.id).call
|
||||||
|
|
||||||
# Deduplicate points based on unique constraint
|
deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp].to_i, point[:user_id]] }
|
||||||
deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] }
|
|
||||||
|
|
||||||
created_points = []
|
created_points = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ module Points
|
||||||
Point.where(user_id: user_id, raw_data_archived: false)
|
Point.where(user_id: user_id, raw_data_archived: false)
|
||||||
.where(timestamp: timestamp_range)
|
.where(timestamp: timestamp_range)
|
||||||
.where.not(raw_data: nil)
|
.where.not(raw_data: nil)
|
||||||
|
.where.not(raw_data: '{}')
|
||||||
end
|
end
|
||||||
|
|
||||||
def month_timestamp_range(year, month)
|
def month_timestamp_range(year, month)
|
||||||
|
|
@ -120,8 +121,7 @@ module Points
|
||||||
Point.transaction do
|
Point.transaction do
|
||||||
Point.where(id: point_ids).update_all(
|
Point.where(id: point_ids).update_all(
|
||||||
raw_data_archived: true,
|
raw_data_archived: true,
|
||||||
raw_data_archive_id: archive_id,
|
raw_data_archive_id: archive_id
|
||||||
raw_data: {}
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
96
app/services/points/raw_data/clearer.rb
Normal file
96
app/services/points/raw_data/clearer.rb
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Points
|
||||||
|
module RawData
|
||||||
|
class Clearer
|
||||||
|
BATCH_SIZE = 10_000
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@stats = { cleared: 0, skipped: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
Rails.logger.info('Starting raw_data clearing for verified archives...')
|
||||||
|
|
||||||
|
verified_archives.find_each do |archive|
|
||||||
|
clear_archive_points(archive)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Clearing complete: #{@stats}")
|
||||||
|
@stats
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_specific_archive(archive_id)
|
||||||
|
archive = Points::RawDataArchive.find(archive_id)
|
||||||
|
|
||||||
|
unless archive.verified_at.present?
|
||||||
|
Rails.logger.warn("Archive #{archive_id} not verified, skipping clear")
|
||||||
|
return { cleared: 0, skipped: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
clear_archive_points(archive)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_month(user_id, year, month)
|
||||||
|
archives = Points::RawDataArchive.for_month(user_id, year, month)
|
||||||
|
.where.not(verified_at: nil)
|
||||||
|
|
||||||
|
Rails.logger.info("Clearing #{archives.count} verified archives for #{year}-#{format('%02d', month)}...")
|
||||||
|
|
||||||
|
archives.each { |archive| clear_archive_points(archive) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def verified_archives
|
||||||
|
# Only archives that are verified but have points with non-empty raw_data
|
||||||
|
Points::RawDataArchive
|
||||||
|
.where.not(verified_at: nil)
|
||||||
|
.where(id: points_needing_clearing.select(:raw_data_archive_id).distinct)
|
||||||
|
end
|
||||||
|
|
||||||
|
def points_needing_clearing
|
||||||
|
Point.where(raw_data_archived: true)
|
||||||
|
.where.not(raw_data: {})
|
||||||
|
.where.not(raw_data_archive_id: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_archive_points(archive)
|
||||||
|
Rails.logger.info(
|
||||||
|
"Clearing points for archive #{archive.id} " \
|
||||||
|
"(#{archive.month_display}, chunk #{archive.chunk_number})..."
|
||||||
|
)
|
||||||
|
|
||||||
|
point_ids = Point.where(raw_data_archive_id: archive.id)
|
||||||
|
.where(raw_data_archived: true)
|
||||||
|
.where.not(raw_data: {})
|
||||||
|
.pluck(:id)
|
||||||
|
|
||||||
|
if point_ids.empty?
|
||||||
|
Rails.logger.info("No points to clear for archive #{archive.id}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
cleared_count = clear_points_in_batches(point_ids)
|
||||||
|
@stats[:cleared] += cleared_count
|
||||||
|
Rails.logger.info("✓ Cleared #{cleared_count} points for archive #{archive.id}")
|
||||||
|
rescue StandardError => e
|
||||||
|
ExceptionReporter.call(e, "Failed to clear points for archive #{archive.id}")
|
||||||
|
Rails.logger.error("✗ Failed to clear archive #{archive.id}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_points_in_batches(point_ids)
|
||||||
|
total_cleared = 0
|
||||||
|
|
||||||
|
point_ids.each_slice(BATCH_SIZE) do |batch|
|
||||||
|
Point.transaction do
|
||||||
|
Point.where(id: batch).update_all(raw_data: {})
|
||||||
|
total_cleared += batch.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
total_cleared
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
199
app/services/points/raw_data/verifier.rb
Normal file
199
app/services/points/raw_data/verifier.rb
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Points
|
||||||
|
module RawData
|
||||||
|
class Verifier
|
||||||
|
def initialize
|
||||||
|
@stats = { verified: 0, failed: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
Rails.logger.info('Starting raw_data archive verification...')
|
||||||
|
|
||||||
|
unverified_archives.find_each do |archive|
|
||||||
|
verify_archive(archive)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Verification complete: #{@stats}")
|
||||||
|
@stats
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_specific_archive(archive_id)
|
||||||
|
archive = Points::RawDataArchive.find(archive_id)
|
||||||
|
verify_archive(archive)
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_month(user_id, year, month)
|
||||||
|
archives = Points::RawDataArchive.for_month(user_id, year, month)
|
||||||
|
.where(verified_at: nil)
|
||||||
|
|
||||||
|
Rails.logger.info("Verifying #{archives.count} archives for #{year}-#{format('%02d', month)}...")
|
||||||
|
|
||||||
|
archives.each { |archive| verify_archive(archive) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def unverified_archives
|
||||||
|
Points::RawDataArchive.where(verified_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_archive(archive)
|
||||||
|
Rails.logger.info("Verifying archive #{archive.id} (#{archive.month_display}, chunk #{archive.chunk_number})...")
|
||||||
|
|
||||||
|
verification_result = perform_verification(archive)
|
||||||
|
|
||||||
|
if verification_result[:success]
|
||||||
|
archive.update!(verified_at: Time.current)
|
||||||
|
@stats[:verified] += 1
|
||||||
|
Rails.logger.info("✓ Archive #{archive.id} verified successfully")
|
||||||
|
else
|
||||||
|
@stats[:failed] += 1
|
||||||
|
Rails.logger.error("✗ Archive #{archive.id} verification failed: #{verification_result[:error]}")
|
||||||
|
ExceptionReporter.call(
|
||||||
|
StandardError.new(verification_result[:error]),
|
||||||
|
"Archive verification failed for archive #{archive.id}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
@stats[:failed] += 1
|
||||||
|
ExceptionReporter.call(e, "Failed to verify archive #{archive.id}")
|
||||||
|
Rails.logger.error("✗ Archive #{archive.id} verification error: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_verification(archive)
|
||||||
|
# 1. Verify file exists and is attached
|
||||||
|
unless archive.file.attached?
|
||||||
|
return { success: false, error: 'File not attached' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# 2. Verify file can be downloaded
|
||||||
|
begin
|
||||||
|
compressed_content = archive.file.blob.download
|
||||||
|
rescue StandardError => e
|
||||||
|
return { success: false, error: "File download failed: #{e.message}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
# 3. Verify file size is reasonable
|
||||||
|
if compressed_content.bytesize.zero?
|
||||||
|
return { success: false, error: 'File is empty' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# 4. Verify MD5 checksum (if blob has checksum)
|
||||||
|
if archive.file.blob.checksum.present?
|
||||||
|
calculated_checksum = Digest::MD5.base64digest(compressed_content)
|
||||||
|
if calculated_checksum != archive.file.blob.checksum
|
||||||
|
return { success: false, error: 'MD5 checksum mismatch' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 5. Verify file can be decompressed and is valid JSONL, extract data
|
||||||
|
begin
|
||||||
|
archived_data = decompress_and_extract_data(compressed_content)
|
||||||
|
rescue StandardError => e
|
||||||
|
return { success: false, error: "Decompression/parsing failed: #{e.message}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
point_ids = archived_data.keys
|
||||||
|
|
||||||
|
# 6. Verify point count matches
|
||||||
|
if point_ids.count != archive.point_count
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Point count mismatch: expected #{archive.point_count}, found #{point_ids.count}"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# 7. Verify point IDs checksum matches
|
||||||
|
calculated_checksum = calculate_checksum(point_ids)
|
||||||
|
if calculated_checksum != archive.point_ids_checksum
|
||||||
|
return { success: false, error: 'Point IDs checksum mismatch' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# 8. Check which points still exist in database (informational only)
|
||||||
|
existing_count = Point.where(id: point_ids).count
|
||||||
|
if existing_count != point_ids.count
|
||||||
|
Rails.logger.info(
|
||||||
|
"Archive #{archive.id}: #{point_ids.count - existing_count} points no longer in database " \
|
||||||
|
"(#{existing_count}/#{point_ids.count} remaining). This is OK if user deleted their data."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# 9. Verify archived raw_data matches current database raw_data (only for existing points)
|
||||||
|
if existing_count.positive?
|
||||||
|
verification_result = verify_raw_data_matches(archived_data)
|
||||||
|
return verification_result unless verification_result[:success]
|
||||||
|
else
|
||||||
|
Rails.logger.info(
|
||||||
|
"Archive #{archive.id}: Skipping raw_data verification - no points remain in database"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{ success: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
def decompress_and_extract_data(compressed_content)
|
||||||
|
io = StringIO.new(compressed_content)
|
||||||
|
gz = Zlib::GzipReader.new(io)
|
||||||
|
archived_data = {}
|
||||||
|
|
||||||
|
gz.each_line do |line|
|
||||||
|
data = JSON.parse(line)
|
||||||
|
archived_data[data['id']] = data['raw_data']
|
||||||
|
end
|
||||||
|
|
||||||
|
gz.close
|
||||||
|
archived_data
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_raw_data_matches(archived_data)
|
||||||
|
# For small archives, verify all points. For large archives, sample up to 100 points.
|
||||||
|
# Always verify all if 100 or fewer points for maximum accuracy
|
||||||
|
if archived_data.size <= 100
|
||||||
|
point_ids_to_check = archived_data.keys
|
||||||
|
else
|
||||||
|
point_ids_to_check = archived_data.keys.sample(100)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter to only check points that still exist in the database
|
||||||
|
existing_point_ids = Point.where(id: point_ids_to_check).pluck(:id)
|
||||||
|
|
||||||
|
if existing_point_ids.empty?
|
||||||
|
# No points remain to verify, but that's OK
|
||||||
|
Rails.logger.info("No points remaining to verify raw_data matches")
|
||||||
|
return { success: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
mismatches = []
|
||||||
|
|
||||||
|
Point.where(id: existing_point_ids).find_each do |point|
|
||||||
|
archived_raw_data = archived_data[point.id]
|
||||||
|
current_raw_data = point.raw_data
|
||||||
|
|
||||||
|
# Compare the raw_data (both should be hashes)
|
||||||
|
if archived_raw_data != current_raw_data
|
||||||
|
mismatches << {
|
||||||
|
point_id: point.id,
|
||||||
|
archived: archived_raw_data,
|
||||||
|
current: current_raw_data
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if mismatches.any?
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Raw data mismatch detected in #{mismatches.count} point(s). " \
|
||||||
|
"First mismatch: Point #{mismatches.first[:point_id]}"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{ success: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_checksum(point_ids)
|
||||||
|
Digest::SHA256.hexdigest(point_ids.sort.join(','))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -9,7 +9,7 @@ class PointsLimitExceeded
|
||||||
return false if DawarichSettings.self_hosted?
|
return false if DawarichSettings.self_hosted?
|
||||||
|
|
||||||
Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
||||||
@user.points_count >= points_limit
|
@user.points_count.to_i >= points_limit
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ class ReverseGeocoding::Places::FetchData
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
def find_place(place_data, existing_places)
|
def find_place(place_data, existing_places)
|
||||||
osm_id = place_data['properties']['osm_id'].to_s
|
osm_id = place_data['properties']['osm_id'].to_s
|
||||||
|
|
||||||
|
|
@ -82,9 +81,9 @@ class ReverseGeocoding::Places::FetchData
|
||||||
|
|
||||||
def find_existing_places(osm_ids)
|
def find_existing_places(osm_ids)
|
||||||
Place.where("geodata->'properties'->>'osm_id' IN (?)", osm_ids)
|
Place.where("geodata->'properties'->>'osm_id' IN (?)", osm_ids)
|
||||||
.global
|
.global
|
||||||
.index_by { |p| p.geodata.dig('properties', 'osm_id').to_s }
|
.index_by { |p| p.geodata.dig('properties', 'osm_id').to_s }
|
||||||
.compact
|
.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_places_for_bulk_operations(places, existing_places)
|
def prepare_places_for_bulk_operations(places, existing_places)
|
||||||
|
|
@ -114,9 +113,9 @@ class ReverseGeocoding::Places::FetchData
|
||||||
place.geodata = data
|
place.geodata = data
|
||||||
place.source = :photon
|
place.source = :photon
|
||||||
|
|
||||||
if place.lonlat.blank?
|
return if place.lonlat.present?
|
||||||
place.lonlat = build_point_coordinates(data['geometry']['coordinates'])
|
|
||||||
end
|
place.lonlat = build_point_coordinates(data['geometry']['coordinates'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def save_places(places_to_create, places_to_update)
|
def save_places(places_to_create, places_to_update)
|
||||||
|
|
@ -138,8 +137,23 @@ class ReverseGeocoding::Places::FetchData
|
||||||
Place.insert_all(place_attributes)
|
Place.insert_all(place_attributes)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Individual updates for existing places
|
return unless places_to_update.any?
|
||||||
places_to_update.each(&:save!) if places_to_update.any?
|
|
||||||
|
update_attributes = places_to_update.map do |place|
|
||||||
|
{
|
||||||
|
id: place.id,
|
||||||
|
name: place.name,
|
||||||
|
latitude: place.latitude,
|
||||||
|
longitude: place.longitude,
|
||||||
|
lonlat: place.lonlat,
|
||||||
|
city: place.city,
|
||||||
|
country: place.country,
|
||||||
|
geodata: place.geodata,
|
||||||
|
source: place.source,
|
||||||
|
updated_at: Time.current
|
||||||
|
}
|
||||||
|
end
|
||||||
|
Place.upsert_all(update_attributes, unique_by: :id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_point_coordinates(coordinates)
|
def build_point_coordinates(coordinates)
|
||||||
|
|
@ -147,7 +161,7 @@ class ReverseGeocoding::Places::FetchData
|
||||||
end
|
end
|
||||||
|
|
||||||
def geocoder_places
|
def geocoder_places
|
||||||
data = Geocoder.search(
|
Geocoder.search(
|
||||||
[place.lat, place.lon],
|
[place.lat, place.lon],
|
||||||
limit: 10,
|
limit: 10,
|
||||||
distance_sort: true,
|
distance_sort: true,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class Stats::CalculateMonth
|
||||||
def start_timestamp = DateTime.new(year, month, 1).to_i
|
def start_timestamp = DateTime.new(year, month, 1).to_i
|
||||||
|
|
||||||
def end_timestamp
|
def end_timestamp
|
||||||
DateTime.new(year, month, -1).to_i # -1 returns last day of month
|
DateTime.new(year, month, -1).to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_month_stats(year, month)
|
def update_month_stats(year, month)
|
||||||
|
|
@ -42,6 +42,8 @@ class Stats::CalculateMonth
|
||||||
)
|
)
|
||||||
|
|
||||||
stat.save!
|
stat.save!
|
||||||
|
|
||||||
|
Cache::InvalidateUserCaches.new(user.id).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ class Stats::HexagonCalculator
|
||||||
# Try with lower resolution (larger hexagons)
|
# Try with lower resolution (larger hexagons)
|
||||||
lower_resolution = [h3_resolution - 2, 0].max
|
lower_resolution = [h3_resolution - 2, 0].max
|
||||||
Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
|
Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
|
||||||
# Create a new instance with lower resolution for recursion
|
# Recursively call with lower resolution
|
||||||
return self.class.new(user.id, year, month).calculate_hexagons(lower_resolution)
|
return calculate_hexagons(lower_resolution)
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}"
|
Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}"
|
||||||
|
|
|
||||||
139
app/services/users/digests/calculate_year.rb
Normal file
139
app/services/users/digests/calculate_year.rb
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module Digests
|
||||||
|
class CalculateYear
|
||||||
|
def initialize(user_id, year)
|
||||||
|
@user = ::User.find(user_id)
|
||||||
|
@year = year.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return nil if monthly_stats.empty?
|
||||||
|
|
||||||
|
digest = Users::Digest.find_or_initialize_by(user: user, year: year, period_type: :yearly)
|
||||||
|
|
||||||
|
digest.assign_attributes(
|
||||||
|
distance: total_distance,
|
||||||
|
toponyms: aggregate_toponyms,
|
||||||
|
monthly_distances: build_monthly_distances,
|
||||||
|
time_spent_by_location: calculate_time_spent,
|
||||||
|
first_time_visits: calculate_first_time_visits,
|
||||||
|
year_over_year: calculate_yoy_comparison,
|
||||||
|
all_time_stats: calculate_all_time_stats
|
||||||
|
)
|
||||||
|
|
||||||
|
digest.save!
|
||||||
|
digest
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :year
|
||||||
|
|
||||||
|
def monthly_stats
|
||||||
|
@monthly_stats ||= user.stats.where(year: year).order(:month)
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_distance
|
||||||
|
monthly_stats.sum(:distance)
|
||||||
|
end
|
||||||
|
|
||||||
|
def aggregate_toponyms
|
||||||
|
country_cities = Hash.new { |h, k| h[k] = Set.new }
|
||||||
|
|
||||||
|
monthly_stats.each do |stat|
|
||||||
|
toponyms = stat.toponyms
|
||||||
|
next unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.each do |toponym|
|
||||||
|
next unless toponym.is_a?(Hash)
|
||||||
|
|
||||||
|
country = toponym['country']
|
||||||
|
next unless country.present?
|
||||||
|
|
||||||
|
if toponym['cities'].is_a?(Array)
|
||||||
|
toponym['cities'].each do |city|
|
||||||
|
city_name = city['city'] if city.is_a?(Hash)
|
||||||
|
country_cities[country].add(city_name) if city_name.present?
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# Ensure country appears even if no cities
|
||||||
|
country_cities[country]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
country_cities.sort_by { |country, _| country }.map do |country, cities|
|
||||||
|
{
|
||||||
|
'country' => country,
|
||||||
|
'cities' => cities.to_a.sort.map { |city| { 'city' => city } }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_monthly_distances
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
monthly_stats.each do |stat|
|
||||||
|
result[stat.month.to_s] = stat.distance.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fill in missing months with 0
|
||||||
|
(1..12).each do |month|
|
||||||
|
result[month.to_s] ||= '0'
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_time_spent
|
||||||
|
country_time = Hash.new(0)
|
||||||
|
city_time = Hash.new(0)
|
||||||
|
|
||||||
|
monthly_stats.each do |stat|
|
||||||
|
toponyms = stat.toponyms
|
||||||
|
next unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.each do |toponym|
|
||||||
|
next unless toponym.is_a?(Hash)
|
||||||
|
|
||||||
|
country = toponym['country']
|
||||||
|
next unless toponym['cities'].is_a?(Array)
|
||||||
|
|
||||||
|
toponym['cities'].each do |city|
|
||||||
|
next unless city.is_a?(Hash)
|
||||||
|
|
||||||
|
stayed_for = city['stayed_for'].to_i
|
||||||
|
city_name = city['city']
|
||||||
|
|
||||||
|
country_time[country] += stayed_for if country.present?
|
||||||
|
city_time[city_name] += stayed_for if city_name.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
'countries' => country_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } },
|
||||||
|
'cities' => city_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_first_time_visits
|
||||||
|
FirstTimeVisitsCalculator.new(user, year).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_yoy_comparison
|
||||||
|
YearOverYearCalculator.new(user, year).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_all_time_stats
|
||||||
|
{
|
||||||
|
'total_countries' => user.countries_visited.count,
|
||||||
|
'total_cities' => user.cities_visited.count,
|
||||||
|
'total_distance' => user.stats.sum(:distance).to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
77
app/services/users/digests/first_time_visits_calculator.rb
Normal file
77
app/services/users/digests/first_time_visits_calculator.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module Digests
|
||||||
|
class FirstTimeVisitsCalculator
|
||||||
|
def initialize(user, year)
|
||||||
|
@user = user
|
||||||
|
@year = year.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
{
|
||||||
|
'countries' => first_time_countries,
|
||||||
|
'cities' => first_time_cities
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :year
|
||||||
|
|
||||||
|
def previous_years_stats
|
||||||
|
@previous_years_stats ||= user.stats.where('year < ?', year)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_year_stats
|
||||||
|
@current_year_stats ||= user.stats.where(year: year)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_countries
|
||||||
|
@previous_countries ||= extract_countries(previous_years_stats)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_cities
|
||||||
|
@previous_cities ||= extract_cities(previous_years_stats)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_countries
|
||||||
|
@current_countries ||= extract_countries(current_year_stats)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_cities
|
||||||
|
@current_cities ||= extract_cities(current_year_stats)
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_time_countries
|
||||||
|
(current_countries - previous_countries).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_time_cities
|
||||||
|
(current_cities - previous_cities).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_countries(stats)
|
||||||
|
stats.flat_map do |stat|
|
||||||
|
toponyms = stat.toponyms
|
||||||
|
next [] unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) && t['country'].present? }
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_cities(stats)
|
||||||
|
stats.flat_map do |stat|
|
||||||
|
toponyms = stat.toponyms
|
||||||
|
next [] unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.flat_map do |t|
|
||||||
|
next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array)
|
||||||
|
|
||||||
|
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) && c['city'].present? }
|
||||||
|
end
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
79
app/services/users/digests/year_over_year_calculator.rb
Normal file
79
app/services/users/digests/year_over_year_calculator.rb
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module Digests
|
||||||
|
class YearOverYearCalculator
|
||||||
|
def initialize(user, year)
|
||||||
|
@user = user
|
||||||
|
@year = year.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return {} unless previous_year_stats.exists?
|
||||||
|
|
||||||
|
{
|
||||||
|
'previous_year' => year - 1,
|
||||||
|
'distance_change_percent' => calculate_distance_change_percent,
|
||||||
|
'countries_change' => calculate_countries_change,
|
||||||
|
'cities_change' => calculate_cities_change
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :year
|
||||||
|
|
||||||
|
def previous_year_stats
|
||||||
|
@previous_year_stats ||= user.stats.where(year: year - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_year_stats
|
||||||
|
@current_year_stats ||= user.stats.where(year: year)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_distance_change_percent
|
||||||
|
prev_distance = previous_year_stats.sum(:distance)
|
||||||
|
return nil if prev_distance.zero?
|
||||||
|
|
||||||
|
curr_distance = current_year_stats.sum(:distance)
|
||||||
|
((curr_distance - prev_distance).to_f / prev_distance * 100).round
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_countries_change
|
||||||
|
prev_count = count_countries(previous_year_stats)
|
||||||
|
curr_count = count_countries(current_year_stats)
|
||||||
|
|
||||||
|
curr_count - prev_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_cities_change
|
||||||
|
prev_count = count_cities(previous_year_stats)
|
||||||
|
curr_count = count_cities(current_year_stats)
|
||||||
|
|
||||||
|
curr_count - prev_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_countries(stats)
|
||||||
|
stats.flat_map do |stat|
|
||||||
|
toponyms = stat.toponyms
|
||||||
|
next [] unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) && t['country'].present? }
|
||||||
|
end.uniq.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_cities(stats)
|
||||||
|
stats.flat_map do |stat|
|
||||||
|
toponyms = stat.toponyms
|
||||||
|
next [] unless toponyms.is_a?(Array)
|
||||||
|
|
||||||
|
toponyms.flat_map do |t|
|
||||||
|
next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array)
|
||||||
|
|
||||||
|
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) && c['city'].present? }
|
||||||
|
end
|
||||||
|
end.uniq.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -323,7 +323,7 @@ class Users::ExportData
|
||||||
trips: user.trips.count,
|
trips: user.trips.count,
|
||||||
stats: user.stats.count,
|
stats: user.stats.count,
|
||||||
notifications: user.notifications.count,
|
notifications: user.notifications.count,
|
||||||
points: user.points_count,
|
points: user.points_count.to_i,
|
||||||
visits: user.visits.count,
|
visits: user.visits.count,
|
||||||
places: user.visited_places.count
|
places: user.visited_places.count
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ require 'oj'
|
||||||
class Users::ImportData
|
class Users::ImportData
|
||||||
STREAM_BATCH_SIZE = 5000
|
STREAM_BATCH_SIZE = 5000
|
||||||
STREAMED_SECTIONS = %w[places visits points].freeze
|
STREAMED_SECTIONS = %w[places visits points].freeze
|
||||||
|
MAX_ENTRY_SIZE = 10.gigabytes # Maximum size for a single file in the archive
|
||||||
|
|
||||||
def initialize(user, archive_path)
|
def initialize(user, archive_path)
|
||||||
@user = user
|
@user = user
|
||||||
|
|
@ -86,6 +87,12 @@ class Users::ImportData
|
||||||
|
|
||||||
Rails.logger.debug "Extracting #{entry.name} to #{extraction_path}"
|
Rails.logger.debug "Extracting #{entry.name} to #{extraction_path}"
|
||||||
|
|
||||||
|
# Validate entry size before extraction
|
||||||
|
if entry.size > MAX_ENTRY_SIZE
|
||||||
|
Rails.logger.error "Skipping oversized entry: #{entry.name} (#{entry.size} bytes exceeds #{MAX_ENTRY_SIZE} bytes)"
|
||||||
|
raise "Archive entry #{entry.name} exceeds maximum allowed size"
|
||||||
|
end
|
||||||
|
|
||||||
FileUtils.mkdir_p(File.dirname(extraction_path))
|
FileUtils.mkdir_p(File.dirname(extraction_path))
|
||||||
|
|
||||||
# Manual extraction to bypass size validation for large files
|
# Manual extraction to bypass size validation for large files
|
||||||
|
|
@ -118,9 +125,7 @@ class Users::ImportData
|
||||||
Rails.logger.info "Starting data import for user: #{user.email}"
|
Rails.logger.info "Starting data import for user: #{user.email}"
|
||||||
|
|
||||||
json_path = @import_directory.join('data.json')
|
json_path = @import_directory.join('data.json')
|
||||||
unless File.exist?(json_path)
|
raise StandardError, 'Data file not found in archive: data.json' unless File.exist?(json_path)
|
||||||
raise StandardError, 'Data file not found in archive: data.json'
|
|
||||||
end
|
|
||||||
|
|
||||||
initialize_stream_state
|
initialize_stream_state
|
||||||
|
|
||||||
|
|
@ -211,10 +216,10 @@ class Users::ImportData
|
||||||
|
|
||||||
@places_batch << place_data
|
@places_batch << place_data
|
||||||
|
|
||||||
if @places_batch.size >= STREAM_BATCH_SIZE
|
return unless @places_batch.size >= STREAM_BATCH_SIZE
|
||||||
import_places_batch(@places_batch)
|
|
||||||
@places_batch.clear
|
import_places_batch(@places_batch)
|
||||||
end
|
@places_batch.clear
|
||||||
end
|
end
|
||||||
|
|
||||||
def flush_places_batch
|
def flush_places_batch
|
||||||
|
|
@ -312,14 +317,16 @@ class Users::ImportData
|
||||||
|
|
||||||
def import_imports(imports_data)
|
def import_imports(imports_data)
|
||||||
Rails.logger.debug "Importing #{imports_data&.size || 0} imports"
|
Rails.logger.debug "Importing #{imports_data&.size || 0} imports"
|
||||||
imports_created, files_restored = Users::ImportData::Imports.new(user, imports_data, @import_directory.join('files')).call
|
imports_created, files_restored = Users::ImportData::Imports.new(user, imports_data,
|
||||||
|
@import_directory.join('files')).call
|
||||||
@import_stats[:imports_created] += imports_created.to_i
|
@import_stats[:imports_created] += imports_created.to_i
|
||||||
@import_stats[:files_restored] += files_restored.to_i
|
@import_stats[:files_restored] += files_restored.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
def import_exports(exports_data)
|
def import_exports(exports_data)
|
||||||
Rails.logger.debug "Importing #{exports_data&.size || 0} exports"
|
Rails.logger.debug "Importing #{exports_data&.size || 0} exports"
|
||||||
exports_created, files_restored = Users::ImportData::Exports.new(user, exports_data, @import_directory.join('files')).call
|
exports_created, files_restored = Users::ImportData::Exports.new(user, exports_data,
|
||||||
|
@import_directory.join('files')).call
|
||||||
@import_stats[:exports_created] += exports_created.to_i
|
@import_stats[:exports_created] += exports_created.to_i
|
||||||
@import_stats[:files_restored] += files_restored.to_i
|
@import_stats[:files_restored] += files_restored.to_i
|
||||||
end
|
end
|
||||||
|
|
@ -388,11 +395,11 @@ class Users::ImportData
|
||||||
expected_counts.each do |entity, expected_count|
|
expected_counts.each do |entity, expected_count|
|
||||||
actual_count = @import_stats[:"#{entity}_created"] || 0
|
actual_count = @import_stats[:"#{entity}_created"] || 0
|
||||||
|
|
||||||
if actual_count < expected_count
|
next unless actual_count < expected_count
|
||||||
discrepancy = "#{entity}: expected #{expected_count}, got #{actual_count} (#{expected_count - actual_count} missing)"
|
|
||||||
discrepancies << discrepancy
|
discrepancy = "#{entity}: expected #{expected_count}, got #{actual_count} (#{expected_count - actual_count} missing)"
|
||||||
Rails.logger.warn "Import discrepancy - #{discrepancy}"
|
discrepancies << discrepancy
|
||||||
end
|
Rails.logger.warn "Import discrepancy - #{discrepancy}"
|
||||||
end
|
end
|
||||||
|
|
||||||
if discrepancies.any?
|
if discrepancies.any?
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,7 @@ class Users::ImportData::Points
|
||||||
country_key = [country_info['name'], country_info['iso_a2'], country_info['iso_a3']]
|
country_key = [country_info['name'], country_info['iso_a2'], country_info['iso_a3']]
|
||||||
country = countries_lookup[country_key]
|
country = countries_lookup[country_key]
|
||||||
|
|
||||||
if country.nil? && country_info['name'].present?
|
country = countries_lookup[country_info['name']] if country.nil? && country_info['name'].present?
|
||||||
country = countries_lookup[country_info['name']]
|
|
||||||
end
|
|
||||||
|
|
||||||
if country
|
if country
|
||||||
attributes['country_id'] = country.id
|
attributes['country_id'] = country.id
|
||||||
|
|
@ -254,12 +252,12 @@ class Users::ImportData::Points
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_lonlat_field(attributes, point_data)
|
def ensure_lonlat_field(attributes, point_data)
|
||||||
if attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present?
|
return unless attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present?
|
||||||
longitude = point_data['longitude'].to_f
|
|
||||||
latitude = point_data['latitude'].to_f
|
longitude = point_data['longitude'].to_f
|
||||||
attributes['lonlat'] = "POINT(#{longitude} #{latitude})"
|
latitude = point_data['latitude'].to_f
|
||||||
logger.debug "Reconstructed lonlat: #{attributes['lonlat']}"
|
attributes['lonlat'] = "POINT(#{longitude} #{latitude})"
|
||||||
end
|
logger.debug "Reconstructed lonlat: #{attributes['lonlat']}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_timestamp_for_lookup(timestamp)
|
def normalize_timestamp_for_lookup(timestamp)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ class Users::SafeSettings
|
||||||
'photoprism_api_key' => nil,
|
'photoprism_api_key' => nil,
|
||||||
'maps' => { 'distance_unit' => 'km' },
|
'maps' => { 'distance_unit' => 'km' },
|
||||||
'visits_suggestions_enabled' => 'true',
|
'visits_suggestions_enabled' => 'true',
|
||||||
'enabled_map_layers' => ['Routes', 'Heatmap'],
|
'enabled_map_layers' => %w[Routes Heatmap],
|
||||||
'maps_maplibre_style' => 'light'
|
'maps_maplibre_style' => 'light',
|
||||||
|
'digest_emails_enabled' => true
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(settings = {})
|
def initialize(settings = {})
|
||||||
|
|
@ -139,4 +140,11 @@ class Users::SafeSettings
|
||||||
def maps_maplibre_style
|
def maps_maplibre_style
|
||||||
settings['maps_maplibre_style']
|
settings['maps_maplibre_style']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def digest_emails_enabled?
|
||||||
|
value = settings['digest_emails_enabled']
|
||||||
|
return true if value.nil?
|
||||||
|
|
||||||
|
ActiveModel::Type::Boolean.new.cast(value)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<p class="py-6">
|
<p class="py-6">
|
||||||
<p class='py-2'>
|
<p class='py-2'>
|
||||||
You have used <%= number_with_delimiter(current_user.points_count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
You have used <%= number_with_delimiter(current_user.points_count.to_i) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||||
</p>
|
</p>
|
||||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points_count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points_count.to_i %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
24
app/views/imports/destroy.turbo_stream.erb
Normal file
24
app/views/imports/destroy.turbo_stream.erb
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<%= turbo_stream.replace "import-#{@import.id}" do %>
|
||||||
|
<tr data-import-id="<%= @import.id %>"
|
||||||
|
id="import-<%= @import.id %>"
|
||||||
|
data-points-total="<%= @import.processed %>"
|
||||||
|
class="hover">
|
||||||
|
<td>
|
||||||
|
<%= @import.name %> (<%= @import.source %>)
|
||||||
|
|
||||||
|
<%= link_to '🗺️', map_path(import_id: @import.id) %>
|
||||||
|
|
||||||
|
<%= link_to '📋', points_path(import_id: @import.id) %>
|
||||||
|
</td>
|
||||||
|
<td><%= number_to_human_size(@import.file&.byte_size) || 'N/A' %></td>
|
||||||
|
<td data-points-count>
|
||||||
|
<%= number_with_delimiter @import.processed %>
|
||||||
|
</td>
|
||||||
|
<td data-status-display>deleting</td>
|
||||||
|
<td><%= human_datetime(@import.created_at) %></td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span class="text-sm text-gray-500">Deleting...</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
|
@ -72,10 +72,15 @@
|
||||||
<td data-status-display><%= import.status %></td>
|
<td data-status-display><%= import.status %></td>
|
||||||
<td><%= human_datetime(import.created_at) %></td>
|
<td><%= human_datetime(import.created_at) %></td>
|
||||||
<td class="whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
<% if import.file.present? %>
|
<% if import.deleting? %>
|
||||||
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span class="text-sm text-gray-500">Deleting...</span>
|
||||||
|
<% else %>
|
||||||
|
<% if import.file.present? %>
|
||||||
|
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
data-maps--maplibre-target="searchInput"
|
data-maps--maplibre-target="searchInput"
|
||||||
autocomplete="off" />
|
autocomplete="off" />
|
||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
<div class="absolute z-50 w-full mt-1 bg-base-100 rounded-lg shadow-lg border border-base-300 hidden max-h-full overflow-y-auto"
|
<div class="absolute z-50 w-full mt-1 bg-base-100 rounded-lg shadow-lg border border-base-300 hidden max-height:400px; overflow-y-auto"
|
||||||
data-maps--maplibre-target="searchResults">
|
data-maps--maplibre-target="searchResults">
|
||||||
<!-- Results will be populated by SearchManager -->
|
<!-- Results will be populated by SearchManager -->
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,6 +317,32 @@
|
||||||
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
|
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if DawarichSettings.family_feature_enabled? %>
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Family Members Layer -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
data-maps--maplibre-target="familyToggle"
|
||||||
|
data-action="change->maps--maplibre#toggleFamily" />
|
||||||
|
<span class="label-text font-medium">Family Members</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm text-base-content/60 ml-14">Show family member locations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Family Members List (conditionally shown) -->
|
||||||
|
<div class="ml-14 space-y-2" data-maps--maplibre-target="familyMembersList" style="display: none;">
|
||||||
|
<div class="text-xs text-base-content/60 mb-2">
|
||||||
|
Click to center on member
|
||||||
|
</div>
|
||||||
|
<div data-maps--maplibre-target="familyMembersContainer" class="space-y-1">
|
||||||
|
<!-- Family members will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||||
|
<%= icon 'mail', class: "text-primary mr-2" %> Email Preferences
|
||||||
|
</h2>
|
||||||
|
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-4">
|
||||||
|
<%= f.check_box :digest_emails_enabled,
|
||||||
|
checked: current_user.safe_settings.digest_emails_enabled?,
|
||||||
|
class: "toggle toggle-primary" %>
|
||||||
|
<div>
|
||||||
|
<span class="label-text font-medium">Year-End Digest Emails</span>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
Receive an annual summary email on January 1st with your year in review
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>
|
<% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<%= number_with_delimiter user.points_count %>
|
<%= number_with_delimiter user.points_count.to_i %>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<%= human_datetime(user.created_at) %>
|
<%= human_datetime(user.created_at) %>
|
||||||
|
|
|
||||||
|
|
@ -131,30 +131,44 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
|
<li data-controller="notifications"
|
||||||
data-controller="notifications"
|
data-notifications-user-id-value="<%= current_user.id %>">
|
||||||
data-notifications-user-id-value="<%= current_user.id %>">
|
<details>
|
||||||
<div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost p-2'>
|
<summary class="relative">
|
||||||
<%= icon 'bell' %>
|
<%= icon 'bell' %>
|
||||||
<% if @unread_notifications.present? %>
|
<% if @unread_notifications.present? %>
|
||||||
<span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge">
|
<span class="badge badge-xs badge-primary absolute top-0 right-0" data-notifications-target="badge">
|
||||||
<%= @unread_notifications.size %>
|
<%= @unread_notifications.size %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</summary>
|
||||||
<ul tabindex="0" class="dropdown-content z-[5000] menu p-2 shadow-lg bg-base-100 rounded-box min-w-52" data-notifications-target="list">
|
<ul class="p-2 bg-base-100 rounded-t-none z-10 min-w-52" data-notifications-target="list">
|
||||||
<li><%= link_to 'See all', notifications_path %></li>
|
<li><%= link_to 'See all', notifications_path %></li>
|
||||||
<% @unread_notifications.first(10).each do |notification| %>
|
<% @unread_notifications.first(10).each do |notification| %>
|
||||||
<div class="divider p-0 m-0"></div>
|
<div class="divider p-0 m-0"></div>
|
||||||
<li class='notification-item'>
|
<li class='notification-item'>
|
||||||
<%= link_to notification do %>
|
<%= link_to notification do %>
|
||||||
<%= notification.title %>
|
<%= notification.title %>
|
||||||
<div class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></div>
|
<div class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<details>
|
||||||
|
<summary><%= icon 'message-circle-question-mark' %></summary>
|
||||||
|
<ul class="p-2 bg-base-100 rounded-box shadow-md z-10 w-52">
|
||||||
|
<li><p>Need help? Ping us! <%= icon 'arrow-big-down' %></p></li>
|
||||||
|
<li><%= link_to 'X (Twitter)', 'https://x.com/freymakesstuff', target: '_blank', rel: 'noopener noreferrer' %></li>
|
||||||
|
<li><%= link_to 'Mastodon', 'https://mastodon.social/@dawarich', target: '_blank', rel: 'noopener noreferrer' %></li>
|
||||||
|
<li><%= link_to 'Email', 'mailto:hi@dawarich.app' %></li>
|
||||||
|
<li><%= link_to 'Forum', 'https://discourse.dawarich.app', target: '_blank', rel: 'noopener noreferrer' %></li>
|
||||||
|
<li><%= link_to 'Discord', 'https://discord.gg/pHsBjpt5J8', target: '_blank', rel: 'noopener noreferrer' %></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@
|
||||||
<%= options_for_select([
|
<%= options_for_select([
|
||||||
['1 hour', '1h'],
|
['1 hour', '1h'],
|
||||||
['12 hours', '12h'],
|
['12 hours', '12h'],
|
||||||
['24 hours', '24h']
|
['24 hours', '24h'],
|
||||||
|
['1 week', '1w'],
|
||||||
|
['1 month', '1m']
|
||||||
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
|
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<% content_for :title, 'Statistics' %>
|
<% content_for :title, 'Statistics' %>
|
||||||
|
|
||||||
<div class="w-full my-5">
|
<div class="w-full my-5">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Statistics</h1>
|
||||||
|
<% if Date.today >= Date.new(2025, 12, 31) %>
|
||||||
|
<%= link_to users_digests_path, class: 'btn btn-outline btn-sm' do %>
|
||||||
|
<%= icon 'earth' %> Year-End Digests
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
|
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
|
||||||
<div class="stat text-center">
|
<div class="stat text-center">
|
||||||
<div class="stat-value text-primary">
|
<div class="stat-value text-primary">
|
||||||
|
|
|
||||||
92
app/views/users/digests/index.html.erb
Normal file
92
app/views/users/digests/index.html.erb
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<% content_for :title, 'Year-End Digests' %>
|
||||||
|
|
||||||
|
<div class="max-w-screen-2xl mx-auto my-5 px-4">
|
||||||
|
<div class="flex justify-between items-center mb-6 gap-8">
|
||||||
|
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<%= icon 'earth' %> Year-End Digests
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<% if @available_years.any? && current_user.active? %>
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<label tabindex="0" class="btn btn-primary">
|
||||||
|
<%= icon 'calendar-plus-2' %> Generate Digest
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
|
<% @available_years.each do |year| %>
|
||||||
|
<li>
|
||||||
|
<%= link_to year, users_digests_path(year: year),
|
||||||
|
data: { turbo_method: :post },
|
||||||
|
class: 'text-base' %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @digests.empty? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body text-center py-12">
|
||||||
|
<h2 class="text-xl font-semibold mb-2 flex items-center justify-center gap-2">
|
||||||
|
<%= icon 'earth' %>No Year-End Digests Yet
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 mb-4">
|
||||||
|
Year-end digests are automatically generated on January 1st each year.
|
||||||
|
<% if @available_years.any? && current_user.active? %>
|
||||||
|
<br>Or you can manually generate one for a previous year.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<% @digests.each do |digest| %>
|
||||||
|
<div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl justify-between">
|
||||||
|
<%= link_to digest.year, users_digest_path(year: digest.year), class: 'hover:text-primary' %>
|
||||||
|
<% if digest.sharing_enabled? %>
|
||||||
|
<span class="badge badge-success badge-sm">Shared</span>
|
||||||
|
<% end %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="stats stats-vertical shadow bg-base-100 mt-4 text-center">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Distance</div>
|
||||||
|
<div class="stat-value text-primary text-lg">
|
||||||
|
<%= distance_with_unit(digest.distance, current_user.safe_settings.distance_unit) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value text-secondary text-lg"><%= digest.countries_count %></div>
|
||||||
|
<div class="stat-title">Countries</div>
|
||||||
|
<% if digest.first_time_countries.any? %>
|
||||||
|
<div class="stat-desc text-success flex items-center gap-1 justify-center">
|
||||||
|
<%= icon 'star' %> <%= digest.first_time_countries.count %> new
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value text-accent text-lg"><%= digest.cities_count %></div>
|
||||||
|
<div class="stat-title">Cities</div>
|
||||||
|
<% if digest.first_time_cities.any? %>
|
||||||
|
<div class="stat-desc text-success flex items-center gap-1 justify-center">
|
||||||
|
<%= icon 'star' %> <%= digest.first_time_cities.count %> new
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<%= link_to users_digest_path(year: digest.year), class: 'btn btn-primary btn-sm' do %>
|
||||||
|
View Details
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
189
app/views/users/digests/public_year.html.erb
Normal file
189
app/views/users/digests/public_year.html.erb
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<div class="max-w-xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background: linear-gradient(135deg, #0f766e, #0284c7);">
|
||||||
|
<div class="hero-content text-center py-12">
|
||||||
|
<div class="max-w-lg">
|
||||||
|
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
|
||||||
|
<p class="py-4">Your journey, by the numbers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distance Card -->
|
||||||
|
<div class="stats shadow mx-auto mb-8 w-full">
|
||||||
|
<div class="stat place-items-center text-center">
|
||||||
|
<div class="stat-title">Distance traveled</div>
|
||||||
|
<div class="stat-value"><%= distance_with_unit(@digest.distance, @distance_unit) %></div>
|
||||||
|
<div class="stat-desc"><%= distance_comparison_text(@digest.distance) %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat place-items-center text-center">
|
||||||
|
<div class="stat-title">Countries visited</div>
|
||||||
|
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
|
||||||
|
<div class="stat-desc <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat place-items-center text-center">
|
||||||
|
<div class="stat-title">Cities explored</div>
|
||||||
|
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
|
||||||
|
<div class="stat-desc <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Time Visits -->
|
||||||
|
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'star' %> First Time Visits
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-semibold mb-2">New Countries</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_countries.each do |country| %>
|
||||||
|
<span class="badge badge-success badge-lg"><%= country %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-2">New Cities</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_cities.take(5).each do |city| %>
|
||||||
|
<span class="badge badge-outline"><%= city %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @digest.first_time_cities.count > 5 %>
|
||||||
|
<span class="badge badge-ghost">+<%= @digest.first_time_cities.count - 5 %> more</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Monthly Distance Chart -->
|
||||||
|
<% if @digest.monthly_distances.present? %>
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'activity' %> Year by Month
|
||||||
|
</h2>
|
||||||
|
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
|
||||||
|
<%= column_chart(
|
||||||
|
@digest.monthly_distances.sort.map { |month, distance_meters|
|
||||||
|
[Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @distance_unit).round]
|
||||||
|
},
|
||||||
|
height: '200px',
|
||||||
|
suffix: " #{@distance_unit}",
|
||||||
|
xtitle: 'Month',
|
||||||
|
ytitle: 'Distance',
|
||||||
|
colors: [
|
||||||
|
'#397bb5', '#5A4E9D', '#3B945E',
|
||||||
|
'#7BC96F', '#FFD54F', '#FFA94D',
|
||||||
|
'#FF6B6B', '#FF8C42', '#C97E4F',
|
||||||
|
'#8B4513', '#5A2E2E', '#265d7d'
|
||||||
|
]
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Top Countries by Time Spent -->
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'map-pin' %> Where They Spent the Most Time
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-2 w-full">
|
||||||
|
<% @digest.top_countries_by_time.take(3).each do |country| %>
|
||||||
|
<li class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['name']) %></span>
|
||||||
|
<%= country['name'] %>
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Countries & Cities -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'earth' %> Countries & Cities
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 w-full">
|
||||||
|
<% @digest.toponyms&.each_with_index do |country, index| %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['country']) %></span>
|
||||||
|
<%= country['country'] %>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm"><%= country['cities']&.length || 0 %> cities</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center w-full">
|
||||||
|
<span class="text-sm font-medium">Cities visited:</span>
|
||||||
|
<% @digest.toponyms&.each do |country| %>
|
||||||
|
<% country['cities']&.take(5)&.each do |city| %>
|
||||||
|
<div class="badge badge-outline"><%= city['city'] %></div>
|
||||||
|
<% end %>
|
||||||
|
<% if country['cities']&.length.to_i > 5 %>
|
||||||
|
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All-Time Stats -->
|
||||||
|
<div class="card bg-slate-800 text-white shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title text-white">
|
||||||
|
<%= icon 'trophy' %> All-Time Stats
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Countries visited</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Cities explored</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center mt-2">
|
||||||
|
<div class="stat-title text-gray-400">Total distance</div>
|
||||||
|
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Powered by <a href="https://dawarich.app" class="link link-primary" target="_blank">Dawarich</a>, your personal memories mapper.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
317
app/views/users/digests/show.html.erb
Normal file
317
app/views/users/digests/show.html.erb
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
<% content_for :title, "#{@digest.year} Year in Review" %>
|
||||||
|
|
||||||
|
<div class="max-w-xl mx-auto my-5">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background: linear-gradient(135deg, #0f766e, #0284c7);">
|
||||||
|
<div class="hero-content text-center py-12 relative w-full">
|
||||||
|
<div class="max-w-lg">
|
||||||
|
<h1 class="text-4xl font-bold"><%= @digest.year %> Year in Review</h1>
|
||||||
|
<p class="py-4">Your journey, by the numbers</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>
|
||||||
|
|
||||||
|
<!-- Distance Card -->
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<div class="stat-title flex items-center gap-2">
|
||||||
|
<%= icon 'map' %> Distance Traveled
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-primary text-4xl my-4">
|
||||||
|
<%= distance_with_unit(@digest.distance, @distance_unit) %>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600"><%= distance_comparison_text(@digest.distance) %></p>
|
||||||
|
<% if @digest.yoy_distance_change %>
|
||||||
|
<p class="mt-2 font-semibold <%= yoy_change_class(@digest.yoy_distance_change) %>">
|
||||||
|
<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Row -->
|
||||||
|
<div class="stats shadow w-full mb-8 bg-base-200">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title flex items-center gap-1">
|
||||||
|
<%= icon 'globe' %> Countries
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
|
||||||
|
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= icon 'star' %> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title flex items-center gap-1">
|
||||||
|
<%= icon 'building' %> Cities
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
|
||||||
|
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
|
||||||
|
<%= icon 'star' %> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Time Visits -->
|
||||||
|
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'star' %> First Time Visits
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-semibold mb-2">New Countries</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_countries.each do |country| %>
|
||||||
|
<span class="badge badge-success badge-lg"><%= country %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold mb-2">New Cities</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center">
|
||||||
|
<% @digest.first_time_cities.take(10).each do |city| %>
|
||||||
|
<span class="badge badge-outline"><%= city %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @digest.first_time_cities.count > 10 %>
|
||||||
|
<span class="badge badge-ghost">+<%= @digest.first_time_cities.count - 10 %> more</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Monthly Distance Chart -->
|
||||||
|
<% if @digest.monthly_distances.present? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'activity' %> Your Year, Month by Month
|
||||||
|
</h2>
|
||||||
|
<div class="w-full h-64 bg-base-100 rounded-lg p-4">
|
||||||
|
<%= column_chart(
|
||||||
|
@digest.monthly_distances.sort.map { |month, distance_meters|
|
||||||
|
[Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @distance_unit).round]
|
||||||
|
},
|
||||||
|
height: '250px',
|
||||||
|
suffix: " #{@distance_unit}",
|
||||||
|
xtitle: 'Month',
|
||||||
|
ytitle: 'Distance',
|
||||||
|
colors: [
|
||||||
|
'#397bb5', '#5A4E9D', '#3B945E',
|
||||||
|
'#7BC96F', '#FFD54F', '#FFA94D',
|
||||||
|
'#FF6B6B', '#FF8C42', '#C97E4F',
|
||||||
|
'#8B4513', '#5A2E2E', '#265d7d'
|
||||||
|
]
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Top Countries by Time Spent -->
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'map-pin' %> Where You Spent the Most Time
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 w-full">
|
||||||
|
<% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %>
|
||||||
|
<div class="flex justify-between items-center p-3 bg-base-100 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="badge badge-lg <%= ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success'][index] %>">
|
||||||
|
<%= index + 1 %>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">
|
||||||
|
<span class="mr-1"><%= country_flag(country['name']) %></span>
|
||||||
|
<%= country['name'] %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- All Countries & Cities -->
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<%= icon 'earth' %> Countries & Cities
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 w-full">
|
||||||
|
<% if @digest.toponyms.present? %>
|
||||||
|
<% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %>
|
||||||
|
<% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
|
||||||
|
|
||||||
|
<% @digest.toponyms.each_with_index do |country, index| %>
|
||||||
|
<% cities_count = country['cities']&.length || 0 %>
|
||||||
|
<% progress_value = max_cities&.positive? ? (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">
|
||||||
|
<span class="mr-1"><%= country_flag(country['country']) %></span>
|
||||||
|
<%= country['country'] %>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm">
|
||||||
|
<%= pluralize(cities_count, 'city') %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress <%= color_class %> w-full" value="<%= progress_value %>" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-500">No location data available</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All-Time Stats Footer -->
|
||||||
|
<div class="card bg-slate-800 text-white shadow-xl mb-8">
|
||||||
|
<div class="card-body text-center items-center">
|
||||||
|
<h2 class="card-title text-white">
|
||||||
|
<%= icon 'trophy' %> All-Time Stats
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Countries visited</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center">
|
||||||
|
<div class="stat-title text-gray-400">Cities explored</div>
|
||||||
|
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat place-items-center mt-2">
|
||||||
|
<div class="stat-title text-gray-400">Total distance</div>
|
||||||
|
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-4 justify-center">
|
||||||
|
<%= link_to users_digests_path, class: 'btn btn-outline' do %>
|
||||||
|
Back to All Digests
|
||||||
|
<% end %>
|
||||||
|
<button class="btn btn-outline" onclick="sharing_modal.showModal()">
|
||||||
|
<%= icon 'share' %> Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sharing 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_users_digest_path(year: @digest.year) %>">
|
||||||
|
|
||||||
|
<!-- 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 @digest.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 year-end digest • Auto-saves on change</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiration Settings (shown when enabled) -->
|
||||||
|
<div data-sharing-modal-target="expirationSettings"
|
||||||
|
class="<%= 'hidden' unless @digest.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'],
|
||||||
|
['1 week', '1w'],
|
||||||
|
['1 month', '1m']
|
||||||
|
], @digest&.sharing_settings&.dig('expiration') || '24h') %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sharing Link Display -->
|
||||||
|
<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="<%= @digest.sharing_enabled? ? shared_users_digest_url(@digest.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 year-end digest</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy Notice -->
|
||||||
|
<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>
|
||||||
298
app/views/users/digests_mailer/year_end_digest.html.erb
Normal file
298
app/views/users/digests_mailer/year_end_digest.html.erb
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #0f766e, #0284c7);
|
||||||
|
color: white;
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.header p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 30px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 16px 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2563eb;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-description {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.first-time-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.comparison {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.comparison.positive {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.comparison.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.chart-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.location-list {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.location-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.location-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.all-time-footer {
|
||||||
|
background: #1e293b;
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.all-time-footer h3 {
|
||||||
|
color: white;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.all-time-stat {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.all-time-stat:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.all-time-stat .label {
|
||||||
|
opacity: 0.8;
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.all-time-stat .value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.unsubscribe {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.unsubscribe a {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1><%= @digest.year %> Year in Review</h1>
|
||||||
|
<p>Your journey, by the numbers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
Hi, this is Evgenii from Dawarich! Pretty wild journey last year, huh? Let's take a look back at all the places you explored in <strong><%= @digest.year %></strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Distance Traveled -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Distance Traveled</div>
|
||||||
|
<p class="stat-value"><%= distance_with_unit(@digest.distance, @distance_unit) %></p>
|
||||||
|
<p class="stat-description"><%= distance_comparison_text(@digest.distance) %></p>
|
||||||
|
<% if @digest.yoy_distance_change %>
|
||||||
|
<p class="comparison <%= yoy_change_class(@digest.yoy_distance_change) %>">
|
||||||
|
<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countries Visited -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Countries Visited</div>
|
||||||
|
<p class="stat-value"><%= @digest.countries_count %></p>
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
<p class="stat-description">
|
||||||
|
<span class="first-time-badge">New</span>
|
||||||
|
First time in: <%= @digest.first_time_countries.join(', ') %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cities Visited -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Cities Explored</div>
|
||||||
|
<p class="stat-value"><%= @digest.cities_count %></p>
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
<p class="stat-description">
|
||||||
|
<span class="first-time-badge">New</span>
|
||||||
|
<% cities_to_show = @digest.first_time_cities.take(5) %>
|
||||||
|
First time in: <%= cities_to_show.join(', ') %>
|
||||||
|
<% if @digest.first_time_cities.count > 5 %>
|
||||||
|
and <%= @digest.first_time_cities.count - 5 %> more
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly Distance Chart -->
|
||||||
|
<% if @digest.monthly_distances.present? %>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3 style="margin: 0 0 16px 0; color: #1e293b;">Your Year, Month by Month</h3>
|
||||||
|
<% max_distance = @digest.monthly_distances.values.map(&:to_i).max %>
|
||||||
|
<% max_distance = 1 if max_distance.zero? %>
|
||||||
|
<% chart_height = 120 %>
|
||||||
|
<% bar_colors = ['#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e'] %>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">
|
||||||
|
<!-- Bars row -->
|
||||||
|
<tr>
|
||||||
|
<% (1..12).each do |month| %>
|
||||||
|
<% distance = @digest.monthly_distances[month.to_s].to_i %>
|
||||||
|
<% bar_height = (distance.to_f / max_distance * chart_height).round %>
|
||||||
|
<% bar_height = 3 if bar_height < 3 && distance > 0 %>
|
||||||
|
<td style="vertical-align: bottom; text-align: center; padding: 0 2px;">
|
||||||
|
<div style="background: <%= bar_colors[month - 1] %>; width: 100%; height: <%= bar_height %>px; border-radius: 3px 3px 0 0; min-height: 3px;"></div>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
<!-- Labels row -->
|
||||||
|
<tr>
|
||||||
|
<% (1..12).each do |month| %>
|
||||||
|
<td style="text-align: center; padding-top: 6px; font-size: 11px; color: #64748b;">
|
||||||
|
<%= Date::ABBR_MONTHNAMES[month][0..0] %>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Top Locations by Time Spent -->
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Where You Spent the Most Time</div>
|
||||||
|
<ul class="location-list">
|
||||||
|
<% @digest.top_countries_by_time.take(3).each do |country| %>
|
||||||
|
<li>
|
||||||
|
<span><%= country_flag(country['name']) %> <%= country['name'] %></span>
|
||||||
|
<span><%= format_time_spent(country['minutes']) %></span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- All-Time Stats Footer -->
|
||||||
|
<div class="all-time-footer">
|
||||||
|
<h3>All-Time Stats</h3>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 16px;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="text-align: center; padding: 8px;">
|
||||||
|
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Countries visited</div>
|
||||||
|
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_countries_all_time %></div>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="text-align: center; padding: 8px;">
|
||||||
|
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Cities explored</div>
|
||||||
|
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_cities_all_time %></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="all-time-stat" style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;">
|
||||||
|
<span class="label">Total distance</span>
|
||||||
|
<span class="value"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
You can open your digest for sharing on its page on Dawarich: <a href="<%= users_digest_url(year: @digest.year) %>"><%= users_digest_url(year: @digest.year) %></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Powered by <a href="https://dawarich.app">Dawarich</a>, your personal location history.</p>
|
||||||
|
<p class="unsubscribe">
|
||||||
|
You can <a href="<%= settings_url(host: ENV.fetch('DOMAIN', 'localhost')) %>">manage your email preferences</a> in settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
app/views/users/digests_mailer/year_end_digest.text.erb
Normal file
41
app/views/users/digests_mailer/year_end_digest.text.erb
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<%= @digest.year %> Year in Review
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Hi, this is Evgenii from Dawarich! Pretty wild journey last year, huh? Let's take a look back at all the places you explored in <%= @digest.year %>.
|
||||||
|
|
||||||
|
DISTANCE TRAVELED
|
||||||
|
<%= distance_with_unit(@digest.distance, @distance_unit) %>
|
||||||
|
<%= distance_comparison_text(@digest.distance) %>
|
||||||
|
<% if @digest.yoy_distance_change %>
|
||||||
|
<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
COUNTRIES VISITED: <%= @digest.countries_count %>
|
||||||
|
<% if @digest.first_time_countries.any? %>
|
||||||
|
First time in: <%= @digest.first_time_countries.join(', ') %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
CITIES EXPLORED: <%= @digest.cities_count %>
|
||||||
|
<% if @digest.first_time_cities.any? %>
|
||||||
|
First time in: <%= @digest.first_time_cities.take(5).join(', ') %><% if @digest.first_time_cities.count > 5 %> and <%= @digest.first_time_cities.count - 5 %> more<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @digest.top_countries_by_time.any? %>
|
||||||
|
WHERE YOU SPENT THE MOST TIME
|
||||||
|
<% @digest.top_countries_by_time.take(3).each do |country| %>
|
||||||
|
- <%= country['name'] %>: <%= format_time_spent(country['minutes']) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
ALL-TIME STATS
|
||||||
|
- <%= @digest.total_countries_all_time %> countries visited
|
||||||
|
- <%= @digest.total_cities_all_time %> cities explored
|
||||||
|
- <%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %> traveled
|
||||||
|
|
||||||
|
Keep exploring, keep discovering. Here's to even more adventures in <%= @digest.year + 1 %>!
|
||||||
|
|
||||||
|
--
|
||||||
|
Powered by Dawarich
|
||||||
|
https://dawarich.app
|
||||||
|
|
||||||
|
Manage your email preferences: <%= settings_url(host: ENV.fetch('DOMAIN', 'localhost')) %>
|
||||||
|
|
@ -37,6 +37,6 @@ module Dawarich
|
||||||
|
|
||||||
config.active_job.queue_adapter = :sidekiq
|
config.active_job.queue_adapter = :sidekiq
|
||||||
|
|
||||||
config.action_mailer.preview_paths << "#{Rails.root.join('spec/mailers/previews')}"
|
config.action_mailer.preview_paths << Rails.root.join('spec/mailers/previews').to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
205
config/initializers/rails_pulse.rb
Normal file
205
config/initializers/rails_pulse.rb
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
RailsPulse.configure do |config|
|
||||||
|
# ====================================================================================================
|
||||||
|
# GLOBAL CONFIGURATION
|
||||||
|
# ====================================================================================================
|
||||||
|
|
||||||
|
# Enable or disable Rails Pulse
|
||||||
|
config.enabled = true
|
||||||
|
|
||||||
|
# ====================================================================================================
|
||||||
|
# THRESHOLDS
|
||||||
|
# ====================================================================================================
|
||||||
|
# These thresholds are used to determine if a route, request, or query is slow, very slow, or critical.
|
||||||
|
# Values are in milliseconds (ms). Adjust these based on your application's performance requirements.
|
||||||
|
|
||||||
|
# Thresholds for an individual route
|
||||||
|
config.route_thresholds = {
|
||||||
|
slow: 500,
|
||||||
|
very_slow: 1500,
|
||||||
|
critical: 3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Thresholds for an individual request
|
||||||
|
config.request_thresholds = {
|
||||||
|
slow: 700,
|
||||||
|
very_slow: 2000,
|
||||||
|
critical: 4000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Thresholds for an individual database query
|
||||||
|
config.query_thresholds = {
|
||||||
|
slow: 100,
|
||||||
|
very_slow: 500,
|
||||||
|
critical: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
# ====================================================================================================
|
||||||
|
# FILTERING
|
||||||
|
# ====================================================================================================
|
||||||
|
|
||||||
|
# Asset Tracking Configuration
|
||||||
|
# By default, Rails Pulse ignores asset requests (images, CSS, JS files) to focus on application performance.
|
||||||
|
# Set track_assets to true if you want to monitor asset delivery performance.
|
||||||
|
config.track_assets = false
|
||||||
|
|
||||||
|
# Custom asset patterns to ignore (in addition to the built-in defaults)
|
||||||
|
# Only applies when track_assets is false. Add patterns for app-specific asset paths.
|
||||||
|
config.custom_asset_patterns = [
|
||||||
|
# Example: ignore specific asset directories
|
||||||
|
# %r{^/uploads/},
|
||||||
|
# %r{^/media/},
|
||||||
|
# "/special-assets/"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Rails Pulse Mount Path (optional)
|
||||||
|
# If Rails Pulse is mounted at a custom path, specify it here to prevent
|
||||||
|
# Rails Pulse from tracking its own requests. Leave as nil for default '/rails_pulse'.
|
||||||
|
# Examples:
|
||||||
|
# config.mount_path = "/admin/monitoring"
|
||||||
|
config.mount_path = nil
|
||||||
|
|
||||||
|
# Manual route filtering
|
||||||
|
# Specify additional routes, requests, or queries to ignore from performance tracking.
|
||||||
|
# Each array can include strings (exact matches) or regular expressions.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# config.ignored_routes = ["/health_check", %r{^/admin}]
|
||||||
|
# config.ignored_requests = ["GET /status", %r{POST /api/v1/.*}]
|
||||||
|
# config.ignored_queries = ["SELECT 1", %r{FROM \"schema_migrations\"}]
|
||||||
|
|
||||||
|
config.ignored_routes = []
|
||||||
|
config.ignored_requests = []
|
||||||
|
config.ignored_queries = []
|
||||||
|
|
||||||
|
# ====================================================================================================
|
||||||
|
# TAGGING
|
||||||
|
# ====================================================================================================
|
||||||
|
# Define custom tags for categorizing routes, requests, and queries.
|
||||||
|
# You can add any custom tags you want for filtering and organization.
|
||||||
|
#
|
||||||
|
# Tag names should be in present tense and describe the current state or category.
|
||||||
|
# Examples of good tag names:
|
||||||
|
# - "critical" (for high-priority endpoints)
|
||||||
|
# - "experimental" (for routes under development)
|
||||||
|
# - "deprecated" (for routes being phased out)
|
||||||
|
# - "external" (for third-party API calls)
|
||||||
|
# - "background" (for async job-related operations)
|
||||||
|
# - "admin" (for administrative routes)
|
||||||
|
# - "public" (for public-facing routes)
|
||||||
|
#
|
||||||
|
# Example configuration:
|
||||||
|
# config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"]
|
||||||
|
|
||||||
|
config.tags = %w[ignored critical experimental]
|
||||||
|
|
||||||
|
# ====================================================================================================
|
||||||
|
# DATABASE CONFIGURATION
|
||||||
|
# ====================================================================================================
|
||||||
|
# Configure Rails Pulse to use a separate database for performance monitoring data.
|
||||||
|
# This is optional but recommended for production applications to isolate performance
|
||||||
|
# data from your main application database.
|
||||||
|
#
|
||||||
|
# Uncomment and configure one of the following patterns:
|
||||||
|
|
||||||
|
# Option 1: Separate single database for Rails Pulse
|
||||||
|
# config.connects_to = {
|
||||||
|
# database: { writing: :rails_pulse, reading: :rails_pulse }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Option 2: Primary/replica configuration for Rails Pulse
|
||||||
|
# config.connects_to = {
|
||||||
|
# database: { writing: :rails_pulse_primary, reading: :rails_pulse_replica }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Don't forget to add the database configuration to config/database.yml:
|
||||||
|
#
|
||||||
|
# production:
|
||||||
|
# # ... your main database config ...
|
||||||
|
# rails_pulse:
|
||||||
|
# adapter: postgresql # or mysql2, sqlite3
|
||||||
|
# database: myapp_rails_pulse_production
|
||||||
|
# username: rails_pulse_user
|
||||||
|
# password: <%= Rails.application.credentials.dig(:rails_pulse, :database_password) %>
|
||||||
|
# host: localhost
|
||||||
|
# pool: 5
|
||||||
|
|
||||||
|
# ====================================================================================================
|
||||||
|
# AUTHENTICATION
|
||||||
|
# ====================================================================================================
|
||||||
|
# Configure authentication to secure access to the Rails Pulse dashboard.
|
||||||
|
# Authentication is ENABLED BY DEFAULT in production environments for security.
|
||||||
|
#
|
||||||
|
# If no authentication method is configured, Rails Pulse will use HTTP Basic Auth
|
||||||
|
# with credentials from RAILS_PULSE_USERNAME (default: 'admin') and RAILS_PULSE_PASSWORD
|
||||||
|
# environment variables. Set RAILS_PULSE_PASSWORD to enable this fallback.
|
||||||
|
#
|
||||||
|
# Uncomment and configure one of the following patterns based on your authentication system:
|
||||||
|
|
||||||
|
# Enable/disable authentication (enabled by default in production)
|
||||||
|
config.authentication_enabled = true
|
||||||
|
|
||||||
|
# Where to redirect unauthorized users
|
||||||
|
config.authentication_redirect_path = '/'
|
||||||
|
|
||||||
|
# Custom authentication method - choose one of the examples below:
|
||||||
|
|
||||||
|
# Example 1: Devise with admin role check
|
||||||
|
# config.authentication_method = proc {
|
||||||
|
# redirect_to main_app.root_path, alert: 'Access denied' unless user_signed_in? && current_user.admin?
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Example 2: Custom session-based authentication
|
||||||
|
# config.authentication_method = proc {
|
||||||
|
# unless session[:user_id] && User.find_by(id: session[:user_id])&.admin?
|
||||||
|
# redirect_to main_app.login_path, alert: "Please log in as an admin"
|
||||||
|
# end
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Example 3: Warden authentication
|
||||||
|
# config.authentication_method = proc {
|
||||||
|
# warden.authenticate!(:scope => :admin)
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Example 4: Basic HTTP authentication
|
||||||
|
config.authentication_method = proc {
|
||||||
|
authenticate_or_request_with_http_basic do |username, password|
|
||||||
|
username == ENV['RAILS_PULSE_USERNAME'] && password == ENV['RAILS_PULSE_PASSWORD']
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
# Example 5: Custom authorization check
|
||||||
|
# config.authentication_method = proc {
|
||||||
|
# current_user = User.find_by(id: session[:user_id])
|
||||||
|
# unless current_user&.can_access_rails_pulse?
|
||||||
|
# render plain: "Forbidden", status: :forbidden
|
||||||
|
# end
|
||||||
|
# }
|
||||||
|
|
||||||
|
# ====================================================================================================
|
||||||
|
# DATA CLEANUP
|
||||||
|
# ====================================================================================================
|
||||||
|
# Configure automatic cleanup of old performance data to manage database size.
|
||||||
|
# Rails Pulse provides two cleanup mechanisms that work together:
|
||||||
|
#
|
||||||
|
# 1. Time-based cleanup: Delete records older than the retention period
|
||||||
|
# 2. Count-based cleanup: Keep only the specified number of records per table
|
||||||
|
#
|
||||||
|
# Cleanup order respects foreign key constraints:
|
||||||
|
# operations → requests → queries/routes
|
||||||
|
|
||||||
|
# Enable or disable automatic data cleanup
|
||||||
|
config.archiving_enabled = true
|
||||||
|
|
||||||
|
# Time-based retention - delete records older than this period
|
||||||
|
config.full_retention_period = 2.weeks
|
||||||
|
|
||||||
|
# Count-based retention - maximum records to keep per table
|
||||||
|
# After time-based cleanup, if tables still exceed these limits,
|
||||||
|
# the oldest remaining records will be deleted to stay under the limit
|
||||||
|
config.max_table_records = {
|
||||||
|
rails_pulse_requests: 10_000, # HTTP requests (moderate volume)
|
||||||
|
rails_pulse_operations: 50_000, # Operations within requests (high volume)
|
||||||
|
rails_pulse_routes: 1000, # Unique routes (low volume)
|
||||||
|
rails_pulse_queries: 500 # Normalized SQL queries (low volume)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
@ -26,6 +26,7 @@ Rails.application.routes.draw do
|
||||||
} do
|
} do
|
||||||
mount Sidekiq::Web => '/sidekiq'
|
mount Sidekiq::Web => '/sidekiq'
|
||||||
end
|
end
|
||||||
|
mount RailsPulse::Engine => '/rails_pulse'
|
||||||
|
|
||||||
# We want to return a nice error message if the user is not authorized to access Sidekiq
|
# We want to return a nice error message if the user is not authorized to access Sidekiq
|
||||||
match '/sidekiq' => redirect { |_, request|
|
match '/sidekiq' => redirect { |_, request|
|
||||||
|
|
@ -98,6 +99,17 @@ Rails.application.routes.draw do
|
||||||
as: :sharing_stats,
|
as: :sharing_stats,
|
||||||
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
|
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
|
||||||
|
|
||||||
|
# User digests routes (yearly/monthly digest reports)
|
||||||
|
scope module: 'users' do
|
||||||
|
resources :digests, only: %i[index create], param: :year, as: :users_digests
|
||||||
|
get 'digests/:year', to: 'digests#show', as: :users_digest, constraints: { year: /\d{4}/ }
|
||||||
|
end
|
||||||
|
get 'shared/digest/:uuid', to: 'shared/digests#show', as: :shared_users_digest
|
||||||
|
patch 'digests/:year/sharing',
|
||||||
|
to: 'shared/digests#update',
|
||||||
|
as: :sharing_users_digest,
|
||||||
|
constraints: { year: /\d{4}/ }
|
||||||
|
|
||||||
root to: 'home#index'
|
root to: 'home#index'
|
||||||
|
|
||||||
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
|
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,12 @@ nightly_family_invitations_cleanup_job:
|
||||||
class: "Family::Invitations::CleanupJob"
|
class: "Family::Invitations::CleanupJob"
|
||||||
queue: family
|
queue: family
|
||||||
|
|
||||||
raw_data_archival_job:
|
rails_pulse_summary_job:
|
||||||
cron: "0 2 1 * *" # Monthly on the 1st at 2 AM
|
cron: "5 * * * *" # every hour at 5 minutes past the hour
|
||||||
class: "Points::RawData::ArchiveJob"
|
class: "RailsPulse::SummaryJob"
|
||||||
queue: archival
|
queue: default
|
||||||
|
|
||||||
|
rails_pulse_clean_up_job:
|
||||||
|
cron: "0 1 * * *" # every day at 01:00
|
||||||
|
class: "RailsPulse::CleanupJob"
|
||||||
|
queue: default
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,4 @@
|
||||||
- app_version_checking
|
- app_version_checking
|
||||||
- cache
|
- cache
|
||||||
- archival
|
- archival
|
||||||
|
- digests
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0]
|
class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0]
|
||||||
def change
|
def change
|
||||||
# safety_assured do
|
execute <<-SQL
|
||||||
execute <<-SQL
|
|
||||||
ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL;
|
ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL;
|
||||||
SQL
|
SQL
|
||||||
# end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0]
|
||||||
|
|
||||||
def change
|
def change
|
||||||
add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true
|
add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true
|
||||||
# safety_assured do
|
add_index :stats, :h3_hex_ids, using: :gin,
|
||||||
add_index :stats, :h3_hex_ids, using: :gin,
|
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
|
||||||
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
|
algorithm: :concurrently, if_not_exists: true
|
||||||
algorithm: :concurrently, if_not_exists: true
|
|
||||||
# end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class CreatePointsRawDataArchives < ActiveRecord::Migration[8.0]
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :points_raw_data_archives, :user_id
|
add_index :points_raw_data_archives, :user_id
|
||||||
add_index :points_raw_data_archives, [:user_id, :year, :month]
|
add_index :points_raw_data_archives, %i[user_id year month]
|
||||||
add_index :points_raw_data_archives, :archived_at
|
add_index :points_raw_data_archives, :archived_at
|
||||||
add_foreign_key :points_raw_data_archives, :users, validate: false
|
add_foreign_key :points_raw_data_archives, :users, validate: false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,59 @@
|
||||||
class AddCompositeIndexToStats < ActiveRecord::Migration[8.0]
|
class AddCompositeIndexToStats < ActiveRecord::Migration[8.0]
|
||||||
disable_ddl_transaction!
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
def change
|
def change
|
||||||
# Add composite index for the most common stats lookup pattern:
|
total_duplicates = execute(<<-SQL.squish).first['count'].to_i
|
||||||
# Stat.find_or_initialize_by(year:, month:, user:)
|
SELECT COUNT(*) as count
|
||||||
# This query is called on EVERY stats calculation
|
FROM stats s1
|
||||||
#
|
WHERE EXISTS (
|
||||||
# Using algorithm: :concurrently to avoid locking the table during index creation
|
SELECT 1 FROM stats s2
|
||||||
# This is crucial for production deployments with existing data
|
WHERE s2.user_id = s1.user_id
|
||||||
|
AND s2.year = s1.year
|
||||||
|
AND s2.month = s1.month
|
||||||
|
AND s2.id > s1.id
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
if total_duplicates.positive?
|
||||||
|
Rails.logger.info(
|
||||||
|
"Found #{total_duplicates} duplicate stats records. Starting cleanup in batches of #{BATCH_SIZE}..."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
loop do
|
||||||
|
batch_deleted = execute(<<-SQL.squish).cmd_tuples
|
||||||
|
DELETE FROM stats
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT s1.id
|
||||||
|
FROM stats s1
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM stats s2
|
||||||
|
WHERE s2.user_id = s1.user_id
|
||||||
|
AND s2.year = s1.year
|
||||||
|
AND s2.month = s1.month
|
||||||
|
AND s2.id > s1.id
|
||||||
|
)
|
||||||
|
LIMIT #{BATCH_SIZE}
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
break if batch_deleted.zero?
|
||||||
|
|
||||||
|
deleted_count += batch_deleted
|
||||||
|
Rails.logger.info("Cleaned up #{deleted_count}/#{total_duplicates} duplicate stats records")
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Completed cleanup: removed #{deleted_count} duplicate stats records") if deleted_count.positive?
|
||||||
|
|
||||||
add_index :stats, %i[user_id year month],
|
add_index :stats, %i[user_id year month],
|
||||||
name: 'index_stats_on_user_id_year_month',
|
name: 'index_stats_on_user_id_year_month',
|
||||||
unique: true,
|
unique: true,
|
||||||
algorithm: :concurrently
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
|
||||||
|
BulkStatsCalculatingJob.perform_later
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddVerifiedAtToPointsRawDataArchives < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :points_raw_data_archives, :verified_at, :datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddCompositeIndexToPointsUserIdTimestamp < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_index :points, %i[user_id timestamp],
|
||||||
|
order: { timestamp: :desc },
|
||||||
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
end
|
||||||
|
end
|
||||||
38
db/migrate/20251227000001_create_digests.rb
Normal file
38
db/migrate/20251227000001_create_digests.rb
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateDigests < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :digests do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.integer :year, null: false
|
||||||
|
t.integer :period_type, null: false, default: 0 # enum: monthly: 0, yearly: 1
|
||||||
|
|
||||||
|
# Aggregated data
|
||||||
|
t.bigint :distance, null: false, default: 0 # Total distance in meters
|
||||||
|
t.jsonb :toponyms, default: {} # Countries/cities data
|
||||||
|
t.jsonb :monthly_distances, default: {} # {1: meters, 2: meters, ...}
|
||||||
|
t.jsonb :time_spent_by_location, default: {} # Top locations by time
|
||||||
|
|
||||||
|
# First-time visits (calculated from historical data)
|
||||||
|
t.jsonb :first_time_visits, default: {} # {countries: [], cities: []}
|
||||||
|
|
||||||
|
# Comparisons
|
||||||
|
t.jsonb :year_over_year, default: {} # {distance_change_percent: 15, ...}
|
||||||
|
t.jsonb :all_time_stats, default: {} # {total_countries: 50, ...}
|
||||||
|
|
||||||
|
# Sharing (like Stat model)
|
||||||
|
t.jsonb :sharing_settings, default: {}
|
||||||
|
t.uuid :sharing_uuid
|
||||||
|
|
||||||
|
# Email tracking
|
||||||
|
t.datetime :sent_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :digests, %i[user_id year period_type], unique: true
|
||||||
|
add_index :digests, :sharing_uuid, unique: true
|
||||||
|
add_index :digests, :year
|
||||||
|
add_index :digests, :period_type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ChangeDigestsDistanceToBigint < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
change_column :digests, :distance, :bigint, null: false, default: 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
change_column :digests, :distance, :integer, null: false, default: 0
|
||||||
|
end
|
||||||
|
end
|
||||||
19
db/migrate/20251228000000_remove_unused_indexes.rb
Normal file
19
db/migrate/20251228000000_remove_unused_indexes.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RemoveUnusedIndexes < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
remove_index :points, :geodata, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, %i[latitude longitude], algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :altitude, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :city, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :country_name, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :battery_status, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :connection, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :trigger, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :battery, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :country, algorithm: :concurrently, if_exists: true
|
||||||
|
remove_index :points, :external_track_id, algorithm: :concurrently, if_exists: true
|
||||||
|
end
|
||||||
|
end
|
||||||
30
db/migrate/20251228100000_add_performance_indexes.rb
Normal file
30
db/migrate/20251228100000_add_performance_indexes.rb
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddPerformanceIndexes < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
# Query: SELECT * FROM users WHERE api_key = $1
|
||||||
|
add_index :users, :api_key,
|
||||||
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
|
||||||
|
# Query: SELECT id FROM users WHERE status = $1
|
||||||
|
add_index :users, :status,
|
||||||
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
|
||||||
|
# Query: SELECT DISTINCT city FROM points WHERE user_id = $1 AND city IS NOT NULL
|
||||||
|
add_index :points, %i[user_id city],
|
||||||
|
name: 'idx_points_user_city',
|
||||||
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
|
||||||
|
# Query: SELECT 1 FROM points WHERE user_id = $1 AND visit_id IS NULL AND timestamp BETWEEN...
|
||||||
|
add_index :points, %i[user_id timestamp],
|
||||||
|
name: 'idx_points_user_visit_null_timestamp',
|
||||||
|
where: 'visit_id IS NULL',
|
||||||
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
end
|
||||||
|
end
|
||||||
23
db/migrate/20251228163703_install_rails_pulse_tables.rb
Normal file
23
db/migrate/20251228163703_install_rails_pulse_tables.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated from Rails Pulse schema - automatically loads current schema definition
|
||||||
|
class InstallRailsPulseTables < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
# Load and execute the Rails Pulse schema directly
|
||||||
|
# This ensures the migration is always in sync with the schema file
|
||||||
|
schema_file = File.join(::Rails.root.to_s, "db/rails_pulse_schema.rb")
|
||||||
|
|
||||||
|
if File.exist?(schema_file)
|
||||||
|
say "Loading Rails Pulse schema from db/rails_pulse_schema.rb"
|
||||||
|
|
||||||
|
# Load the schema file to define RailsPulse::Schema
|
||||||
|
load schema_file
|
||||||
|
|
||||||
|
# Execute the schema in the context of this migration
|
||||||
|
RailsPulse::Schema.call(connection)
|
||||||
|
|
||||||
|
say "Rails Pulse tables created successfully"
|
||||||
|
say "The schema file db/rails_pulse_schema.rb remains as your single source of truth"
|
||||||
|
else
|
||||||
|
raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
0
db/rails_pulse_migrate/.keep
Normal file
0
db/rails_pulse_migrate/.keep
Normal file
133
db/rails_pulse_schema.rb
Normal file
133
db/rails_pulse_schema.rb
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Rails Pulse Database Schema
|
||||||
|
# This file contains the complete schema for Rails Pulse tables
|
||||||
|
# Load with: rails db:schema:load:rails_pulse or db:prepare
|
||||||
|
|
||||||
|
RailsPulse::Schema = lambda do |connection|
|
||||||
|
# Skip if all tables already exist to prevent conflicts
|
||||||
|
required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ]
|
||||||
|
|
||||||
|
if ENV["CI"] == "true"
|
||||||
|
existing_tables = required_tables.select { |table| connection.table_exists?(table) }
|
||||||
|
missing_tables = required_tables - existing_tables
|
||||||
|
puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any?
|
||||||
|
puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any?
|
||||||
|
end
|
||||||
|
|
||||||
|
return if required_tables.all? { |table| connection.table_exists?(table) }
|
||||||
|
|
||||||
|
connection.create_table :rails_pulse_routes do |t|
|
||||||
|
t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)"
|
||||||
|
t.string :path, null: false, comment: "Request path (e.g., /posts/index)"
|
||||||
|
t.text :tags, comment: "JSON array of tags for filtering and categorization"
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path"
|
||||||
|
|
||||||
|
connection.create_table :rails_pulse_queries do |t|
|
||||||
|
t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
|
||||||
|
t.datetime :analyzed_at, comment: "When query analysis was last performed"
|
||||||
|
t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution"
|
||||||
|
t.text :issues, comment: "JSON array of detected performance issues"
|
||||||
|
t.text :metadata, comment: "JSON object containing query complexity metrics"
|
||||||
|
t.text :query_stats, comment: "JSON object with query characteristics analysis"
|
||||||
|
t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection"
|
||||||
|
t.text :index_recommendations, comment: "JSON array of database index recommendations"
|
||||||
|
t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results"
|
||||||
|
t.text :suggestions, comment: "JSON array of optimization recommendations"
|
||||||
|
t.text :tags, comment: "JSON array of tags for filtering and categorization"
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191
|
||||||
|
|
||||||
|
connection.create_table :rails_pulse_requests do |t|
|
||||||
|
t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route"
|
||||||
|
t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
|
||||||
|
t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)"
|
||||||
|
t.boolean :is_error, null: false, default: false, comment: "True if status >= 500"
|
||||||
|
t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)"
|
||||||
|
t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)"
|
||||||
|
t.timestamp :occurred_at, null: false, comment: "When the request started"
|
||||||
|
t.text :tags, comment: "JSON array of tags for filtering and categorization"
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at"
|
||||||
|
connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid"
|
||||||
|
connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at"
|
||||||
|
|
||||||
|
connection.create_table :rails_pulse_operations do |t|
|
||||||
|
t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request"
|
||||||
|
t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query"
|
||||||
|
t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)"
|
||||||
|
t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)"
|
||||||
|
t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds"
|
||||||
|
t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)"
|
||||||
|
t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds"
|
||||||
|
t.timestamp :occurred_at, null: false, comment: "When the request started"
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type"
|
||||||
|
connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at"
|
||||||
|
connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time"
|
||||||
|
connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance"
|
||||||
|
connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type"
|
||||||
|
|
||||||
|
connection.create_table :rails_pulse_summaries do |t|
|
||||||
|
# Time fields
|
||||||
|
t.datetime :period_start, null: false, comment: "Start of the aggregation period"
|
||||||
|
t.datetime :period_end, null: false, comment: "End of the aggregation period"
|
||||||
|
t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month"
|
||||||
|
|
||||||
|
# Polymorphic association to handle both routes and queries
|
||||||
|
t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query"
|
||||||
|
# This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query')
|
||||||
|
# and summarizable_id (route_id or query_id)
|
||||||
|
|
||||||
|
# Universal metrics
|
||||||
|
t.integer :count, default: 0, null: false, comment: "Total number of requests/operations"
|
||||||
|
t.float :avg_duration, comment: "Average duration in milliseconds"
|
||||||
|
t.float :min_duration, comment: "Minimum duration in milliseconds"
|
||||||
|
t.float :max_duration, comment: "Maximum duration in milliseconds"
|
||||||
|
t.float :p50_duration, comment: "50th percentile duration"
|
||||||
|
t.float :p95_duration, comment: "95th percentile duration"
|
||||||
|
t.float :p99_duration, comment: "99th percentile duration"
|
||||||
|
t.float :total_duration, comment: "Total duration in milliseconds"
|
||||||
|
t.float :stddev_duration, comment: "Standard deviation of duration"
|
||||||
|
|
||||||
|
# Request/Route specific metrics
|
||||||
|
t.integer :error_count, default: 0, comment: "Number of error responses (5xx)"
|
||||||
|
t.integer :success_count, default: 0, comment: "Number of successful responses"
|
||||||
|
t.integer :status_2xx, default: 0, comment: "Number of 2xx responses"
|
||||||
|
t.integer :status_3xx, default: 0, comment: "Number of 3xx responses"
|
||||||
|
t.integer :status_4xx, default: 0, comment: "Number of 4xx responses"
|
||||||
|
t.integer :status_5xx, default: 0, comment: "Number of 5xx responses"
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unique constraint and indexes for summaries
|
||||||
|
connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ],
|
||||||
|
unique: true,
|
||||||
|
name: "idx_pulse_summaries_unique"
|
||||||
|
connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period"
|
||||||
|
connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at"
|
||||||
|
|
||||||
|
# Add indexes to existing tables for efficient aggregation
|
||||||
|
connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation"
|
||||||
|
connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at"
|
||||||
|
|
||||||
|
connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation"
|
||||||
|
connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at"
|
||||||
|
|
||||||
|
if ENV["CI"] == "true"
|
||||||
|
created_tables = required_tables.select { |table| connection.table_exists?(table) }
|
||||||
|
puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if defined?(RailsPulse::ApplicationRecord)
|
||||||
|
RailsPulse::Schema.call(RailsPulse::ApplicationRecord.connection)
|
||||||
|
end
|
||||||
142
db/schema.rb
generated
142
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
ActiveRecord::Schema[8.0].define(version: 2025_12_28_163703) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
enable_extension "postgis"
|
enable_extension "postgis"
|
||||||
|
|
@ -80,6 +80,29 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "digests", force: :cascade do |t|
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.integer "year", null: false
|
||||||
|
t.integer "period_type", default: 0, null: false
|
||||||
|
t.bigint "distance", default: 0, null: false
|
||||||
|
t.jsonb "toponyms", default: {}
|
||||||
|
t.jsonb "monthly_distances", default: {}
|
||||||
|
t.jsonb "time_spent_by_location", default: {}
|
||||||
|
t.jsonb "first_time_visits", default: {}
|
||||||
|
t.jsonb "year_over_year", default: {}
|
||||||
|
t.jsonb "all_time_stats", default: {}
|
||||||
|
t.jsonb "sharing_settings", default: {}
|
||||||
|
t.uuid "sharing_uuid"
|
||||||
|
t.datetime "sent_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["period_type"], name: "index_digests_on_period_type"
|
||||||
|
t.index ["sharing_uuid"], name: "index_digests_on_sharing_uuid", unique: true
|
||||||
|
t.index ["user_id", "year", "period_type"], name: "index_digests_on_user_id_and_year_and_period_type", unique: true
|
||||||
|
t.index ["user_id"], name: "index_digests_on_user_id"
|
||||||
|
t.index ["year"], name: "index_digests_on_year"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "exports", force: :cascade do |t|
|
create_table "exports", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "url"
|
t.string "url"
|
||||||
|
|
@ -226,18 +249,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
t.string "country_name"
|
t.string "country_name"
|
||||||
t.boolean "raw_data_archived", default: false, null: false
|
t.boolean "raw_data_archived", default: false, null: false
|
||||||
t.bigint "raw_data_archive_id"
|
t.bigint "raw_data_archive_id"
|
||||||
t.index ["altitude"], name: "index_points_on_altitude"
|
|
||||||
t.index ["battery"], name: "index_points_on_battery"
|
|
||||||
t.index ["battery_status"], name: "index_points_on_battery_status"
|
|
||||||
t.index ["city"], name: "index_points_on_city"
|
|
||||||
t.index ["connection"], name: "index_points_on_connection"
|
|
||||||
t.index ["country"], name: "index_points_on_country"
|
|
||||||
t.index ["country_id"], name: "index_points_on_country_id"
|
t.index ["country_id"], name: "index_points_on_country_id"
|
||||||
t.index ["country_name"], name: "index_points_on_country_name"
|
|
||||||
t.index ["external_track_id"], name: "index_points_on_external_track_id"
|
|
||||||
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
|
|
||||||
t.index ["import_id"], name: "index_points_on_import_id"
|
t.index ["import_id"], name: "index_points_on_import_id"
|
||||||
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
|
|
||||||
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
|
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
|
||||||
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
|
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
|
||||||
t.index ["raw_data_archive_id"], name: "index_points_on_raw_data_archive_id"
|
t.index ["raw_data_archive_id"], name: "index_points_on_raw_data_archive_id"
|
||||||
|
|
@ -245,10 +258,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
||||||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||||
t.index ["track_id"], name: "index_points_on_track_id"
|
t.index ["track_id"], name: "index_points_on_track_id"
|
||||||
t.index ["trigger"], name: "index_points_on_trigger"
|
t.index ["user_id", "city"], name: "idx_points_user_city"
|
||||||
t.index ["user_id", "country_name"], name: "idx_points_user_country_name"
|
t.index ["user_id", "country_name"], name: "idx_points_user_country_name"
|
||||||
t.index ["user_id", "reverse_geocoded_at"], name: "index_points_on_user_id_and_reverse_geocoded_at", where: "(reverse_geocoded_at IS NOT NULL)"
|
t.index ["user_id", "reverse_geocoded_at"], name: "index_points_on_user_id_and_reverse_geocoded_at", where: "(reverse_geocoded_at IS NOT NULL)"
|
||||||
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
|
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
|
||||||
|
t.index ["user_id", "timestamp"], name: "idx_points_user_visit_null_timestamp", where: "(visit_id IS NULL)"
|
||||||
|
t.index ["user_id", "timestamp"], name: "index_points_on_user_id_and_timestamp", order: { timestamp: :desc }
|
||||||
t.index ["user_id"], name: "index_points_on_user_id"
|
t.index ["user_id"], name: "index_points_on_user_id"
|
||||||
t.index ["visit_id"], name: "index_points_on_visit_id"
|
t.index ["visit_id"], name: "index_points_on_visit_id"
|
||||||
end
|
end
|
||||||
|
|
@ -264,11 +279,108 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
t.datetime "archived_at", null: false
|
t.datetime "archived_at", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "verified_at"
|
||||||
t.index ["archived_at"], name: "index_points_raw_data_archives_on_archived_at"
|
t.index ["archived_at"], name: "index_points_raw_data_archives_on_archived_at"
|
||||||
t.index ["user_id", "year", "month"], name: "index_points_raw_data_archives_on_user_id_and_year_and_month"
|
t.index ["user_id", "year", "month"], name: "index_points_raw_data_archives_on_user_id_and_year_and_month"
|
||||||
t.index ["user_id"], name: "index_points_raw_data_archives_on_user_id"
|
t.index ["user_id"], name: "index_points_raw_data_archives_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "rails_pulse_operations", force: :cascade do |t|
|
||||||
|
t.bigint "request_id", null: false, comment: "Link to the request"
|
||||||
|
t.bigint "query_id", comment: "Link to the normalized SQL query"
|
||||||
|
t.string "operation_type", null: false, comment: "Type of operation (e.g., database, view, gem_call)"
|
||||||
|
t.string "label", null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)"
|
||||||
|
t.decimal "duration", precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds"
|
||||||
|
t.string "codebase_location", comment: "File and line number (e.g., app/models/user.rb:25)"
|
||||||
|
t.float "start_time", default: 0.0, null: false, comment: "Operation start time in milliseconds"
|
||||||
|
t.datetime "occurred_at", precision: nil, null: false, comment: "When the request started"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_at", "query_id"], name: "idx_operations_for_aggregation"
|
||||||
|
t.index ["created_at"], name: "idx_operations_created_at"
|
||||||
|
t.index ["occurred_at", "duration", "operation_type"], name: "index_rails_pulse_operations_on_time_duration_type"
|
||||||
|
t.index ["occurred_at"], name: "index_rails_pulse_operations_on_occurred_at"
|
||||||
|
t.index ["operation_type"], name: "index_rails_pulse_operations_on_operation_type"
|
||||||
|
t.index ["query_id", "duration", "occurred_at"], name: "index_rails_pulse_operations_query_performance"
|
||||||
|
t.index ["query_id", "occurred_at"], name: "index_rails_pulse_operations_on_query_and_time"
|
||||||
|
t.index ["query_id"], name: "index_rails_pulse_operations_on_query_id"
|
||||||
|
t.index ["request_id"], name: "index_rails_pulse_operations_on_request_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "rails_pulse_queries", force: :cascade do |t|
|
||||||
|
t.string "normalized_sql", limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)"
|
||||||
|
t.datetime "analyzed_at", comment: "When query analysis was last performed"
|
||||||
|
t.text "explain_plan", comment: "EXPLAIN output from actual SQL execution"
|
||||||
|
t.text "issues", comment: "JSON array of detected performance issues"
|
||||||
|
t.text "metadata", comment: "JSON object containing query complexity metrics"
|
||||||
|
t.text "query_stats", comment: "JSON object with query characteristics analysis"
|
||||||
|
t.text "backtrace_analysis", comment: "JSON object with call chain and N+1 detection"
|
||||||
|
t.text "index_recommendations", comment: "JSON array of database index recommendations"
|
||||||
|
t.text "n_plus_one_analysis", comment: "JSON object with enhanced N+1 query detection results"
|
||||||
|
t.text "suggestions", comment: "JSON array of optimization recommendations"
|
||||||
|
t.text "tags", comment: "JSON array of tags for filtering and categorization"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["normalized_sql"], name: "index_rails_pulse_queries_on_normalized_sql", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "rails_pulse_requests", force: :cascade do |t|
|
||||||
|
t.bigint "route_id", null: false, comment: "Link to the route"
|
||||||
|
t.decimal "duration", precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds"
|
||||||
|
t.integer "status", null: false, comment: "HTTP status code (e.g., 200, 500)"
|
||||||
|
t.boolean "is_error", default: false, null: false, comment: "True if status >= 500"
|
||||||
|
t.string "request_uuid", null: false, comment: "Unique identifier for the request (e.g., UUID)"
|
||||||
|
t.string "controller_action", comment: "Controller and action handling the request (e.g., PostsController#show)"
|
||||||
|
t.datetime "occurred_at", precision: nil, null: false, comment: "When the request started"
|
||||||
|
t.text "tags", comment: "JSON array of tags for filtering and categorization"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_at", "route_id"], name: "idx_requests_for_aggregation"
|
||||||
|
t.index ["created_at"], name: "idx_requests_created_at"
|
||||||
|
t.index ["occurred_at"], name: "index_rails_pulse_requests_on_occurred_at"
|
||||||
|
t.index ["request_uuid"], name: "index_rails_pulse_requests_on_request_uuid", unique: true
|
||||||
|
t.index ["route_id", "occurred_at"], name: "index_rails_pulse_requests_on_route_id_and_occurred_at"
|
||||||
|
t.index ["route_id"], name: "index_rails_pulse_requests_on_route_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "rails_pulse_routes", force: :cascade do |t|
|
||||||
|
t.string "method", null: false, comment: "HTTP method (e.g., GET, POST)"
|
||||||
|
t.string "path", null: false, comment: "Request path (e.g., /posts/index)"
|
||||||
|
t.text "tags", comment: "JSON array of tags for filtering and categorization"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["method", "path"], name: "index_rails_pulse_routes_on_method_and_path", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "rails_pulse_summaries", force: :cascade do |t|
|
||||||
|
t.datetime "period_start", null: false, comment: "Start of the aggregation period"
|
||||||
|
t.datetime "period_end", null: false, comment: "End of the aggregation period"
|
||||||
|
t.string "period_type", null: false, comment: "Aggregation period type: hour, day, week, month"
|
||||||
|
t.string "summarizable_type", null: false
|
||||||
|
t.bigint "summarizable_id", null: false, comment: "Link to Route or Query"
|
||||||
|
t.integer "count", default: 0, null: false, comment: "Total number of requests/operations"
|
||||||
|
t.float "avg_duration", comment: "Average duration in milliseconds"
|
||||||
|
t.float "min_duration", comment: "Minimum duration in milliseconds"
|
||||||
|
t.float "max_duration", comment: "Maximum duration in milliseconds"
|
||||||
|
t.float "p50_duration", comment: "50th percentile duration"
|
||||||
|
t.float "p95_duration", comment: "95th percentile duration"
|
||||||
|
t.float "p99_duration", comment: "99th percentile duration"
|
||||||
|
t.float "total_duration", comment: "Total duration in milliseconds"
|
||||||
|
t.float "stddev_duration", comment: "Standard deviation of duration"
|
||||||
|
t.integer "error_count", default: 0, comment: "Number of error responses (5xx)"
|
||||||
|
t.integer "success_count", default: 0, comment: "Number of successful responses"
|
||||||
|
t.integer "status_2xx", default: 0, comment: "Number of 2xx responses"
|
||||||
|
t.integer "status_3xx", default: 0, comment: "Number of 3xx responses"
|
||||||
|
t.integer "status_4xx", default: 0, comment: "Number of 4xx responses"
|
||||||
|
t.integer "status_5xx", default: 0, comment: "Number of 5xx responses"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["created_at"], name: "index_rails_pulse_summaries_on_created_at"
|
||||||
|
t.index ["period_type", "period_start"], name: "index_rails_pulse_summaries_on_period"
|
||||||
|
t.index ["summarizable_type", "summarizable_id", "period_type", "period_start"], name: "idx_pulse_summaries_unique", unique: true
|
||||||
|
t.index ["summarizable_type", "summarizable_id"], name: "index_rails_pulse_summaries_on_summarizable"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stats", force: :cascade do |t|
|
create_table "stats", force: :cascade do |t|
|
||||||
t.integer "year", null: false
|
t.integer "year", null: false
|
||||||
t.integer "month", null: false
|
t.integer "month", null: false
|
||||||
|
|
@ -371,9 +483,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
t.string "utm_campaign"
|
t.string "utm_campaign"
|
||||||
t.string "utm_term"
|
t.string "utm_term"
|
||||||
t.string "utm_content"
|
t.string "utm_content"
|
||||||
|
t.index ["api_key"], name: "index_users_on_api_key"
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
|
t.index ["status"], name: "index_users_on_status"
|
||||||
end
|
end
|
||||||
|
|
||||||
add_check_constraint "users", "admin IS NOT NULL", name: "users_admin_null", validate: false
|
add_check_constraint "users", "admin IS NOT NULL", name: "users_admin_null", validate: false
|
||||||
|
|
@ -398,6 +512,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "areas", "users"
|
add_foreign_key "areas", "users"
|
||||||
|
add_foreign_key "digests", "users"
|
||||||
add_foreign_key "families", "users", column: "creator_id"
|
add_foreign_key "families", "users", column: "creator_id"
|
||||||
add_foreign_key "family_invitations", "families"
|
add_foreign_key "family_invitations", "families"
|
||||||
add_foreign_key "family_invitations", "users", column: "invited_by_id"
|
add_foreign_key "family_invitations", "users", column: "invited_by_id"
|
||||||
|
|
@ -410,6 +525,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_08_210410) do
|
||||||
add_foreign_key "points", "users"
|
add_foreign_key "points", "users"
|
||||||
add_foreign_key "points", "visits"
|
add_foreign_key "points", "visits"
|
||||||
add_foreign_key "points_raw_data_archives", "users"
|
add_foreign_key "points_raw_data_archives", "users"
|
||||||
|
add_foreign_key "rails_pulse_operations", "rails_pulse_queries", column: "query_id"
|
||||||
|
add_foreign_key "rails_pulse_operations", "rails_pulse_requests", column: "request_id"
|
||||||
|
add_foreign_key "rails_pulse_requests", "rails_pulse_routes", column: "route_id"
|
||||||
add_foreign_key "stats", "users"
|
add_foreign_key "stats", "users"
|
||||||
add_foreign_key "taggings", "tags"
|
add_foreign_key "taggings", "tags"
|
||||||
add_foreign_key "tags", "users"
|
add_foreign_key "tags", "users"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ if Tag.none?
|
||||||
{ name: 'Home', color: '#FF5733', icon: '🏡' },
|
{ name: 'Home', color: '#FF5733', icon: '🏡' },
|
||||||
{ name: 'Work', color: '#33FF57', icon: '💼' },
|
{ name: 'Work', color: '#33FF57', icon: '💼' },
|
||||||
{ name: 'Favorite', color: '#3357FF', icon: '⭐' },
|
{ name: 'Favorite', color: '#3357FF', icon: '⭐' },
|
||||||
{ name: 'Travel Plans', color: '#F1C40F', icon: '🗺️' },
|
{ name: 'Travel Plans', color: '#F1C40F', icon: '🗺️' }
|
||||||
]
|
]
|
||||||
|
|
||||||
User.find_each do |user|
|
User.find_each do |user|
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,11 @@ echo "⚠️ Starting Sidekiq in $RAILS_ENV environment ⚠️"
|
||||||
# Parse DATABASE_URL if present, otherwise use individual variables
|
# Parse DATABASE_URL if present, otherwise use individual variables
|
||||||
if [ -n "$DATABASE_URL" ]; then
|
if [ -n "$DATABASE_URL" ]; then
|
||||||
# Extract components from DATABASE_URL
|
# Extract components from DATABASE_URL
|
||||||
DATABASE_HOST=$(echo $DATABASE_URL | awk -F[@/] '{print $4}')
|
DATABASE_HOST="$(echo "$DATABASE_URL" | awk -F[@/] '{print $4}')"
|
||||||
DATABASE_PORT=$(echo $DATABASE_URL | awk -F[@/:] '{print $5}')
|
DATABASE_PORT="$(echo "$DATABASE_URL" | awk -F[@/:] '{print $5}')"
|
||||||
DATABASE_USERNAME=$(echo $DATABASE_URL | awk -F[:/@] '{print $4}')
|
DATABASE_USERNAME="$(echo "$DATABASE_URL" | awk -F[:/@] '{print $4}')"
|
||||||
DATABASE_PASSWORD=$(echo $DATABASE_URL | awk -F[:/@] '{print $5}')
|
DATABASE_PASSWORD="$(echo "$DATABASE_URL" | awk -F[:/@] '{print $5}')"
|
||||||
DATABASE_NAME=$(echo $DATABASE_URL | awk -F[@/] '{print $5}')
|
DATABASE_NAME="$(echo "$DATABASE_URL" | awk -F[@/] '{print $5}')"
|
||||||
else
|
|
||||||
# Use existing environment variables
|
|
||||||
DATABASE_HOST=${DATABASE_HOST}
|
|
||||||
DATABASE_PORT=${DATABASE_PORT}
|
|
||||||
DATABASE_USERNAME=${DATABASE_USERNAME}
|
|
||||||
DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
|
||||||
DATABASE_NAME=${DATABASE_NAME}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wait for the database to become available
|
# Wait for the database to become available
|
||||||
|
|
@ -33,4 +26,4 @@ done
|
||||||
echo "✅ PostgreSQL is ready!"
|
echo "✅ PostgreSQL is ready!"
|
||||||
|
|
||||||
# run sidekiq
|
# run sidekiq
|
||||||
bundle exec sidekiq
|
exec bundle exec sidekiq
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,11 @@ echo "⚠️ Starting Rails environment: $RAILS_ENV ⚠️"
|
||||||
# Parse DATABASE_URL if present, otherwise use individual variables
|
# Parse DATABASE_URL if present, otherwise use individual variables
|
||||||
if [ -n "$DATABASE_URL" ]; then
|
if [ -n "$DATABASE_URL" ]; then
|
||||||
# Extract components from DATABASE_URL
|
# Extract components from DATABASE_URL
|
||||||
DATABASE_HOST=$(echo $DATABASE_URL | awk -F[@/] '{print $4}')
|
DATABASE_HOST="$(echo "$DATABASE_URL" | awk -F[@/] '{print $4}')"
|
||||||
DATABASE_PORT=$(echo $DATABASE_URL | awk -F[@/:] '{print $5}')
|
DATABASE_PORT="$(echo "$DATABASE_URL" | awk -F[@/:] '{print $5}')"
|
||||||
DATABASE_USERNAME=$(echo $DATABASE_URL | awk -F[:/@] '{print $4}')
|
DATABASE_USERNAME="$(echo "$DATABASE_URL" | awk -F[:/@] '{print $4}')"
|
||||||
DATABASE_PASSWORD=$(echo $DATABASE_URL | awk -F[:/@] '{print $5}')
|
DATABASE_PASSWORD="$(echo "$DATABASE_URL" | awk -F[:/@] '{print $5}')"
|
||||||
DATABASE_NAME=$(echo $DATABASE_URL | awk -F[@/] '{print $5}')
|
DATABASE_NAME="$(echo "$DATABASE_URL" | awk -F[@/] '{print $5}')"
|
||||||
else
|
|
||||||
# Use existing environment variables
|
|
||||||
DATABASE_HOST=${DATABASE_HOST}
|
|
||||||
DATABASE_PORT=${DATABASE_PORT}
|
|
||||||
DATABASE_USERNAME=${DATABASE_USERNAME}
|
|
||||||
DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
|
||||||
DATABASE_NAME=${DATABASE_NAME}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Export main database variables to ensure they're available
|
# Export main database variables to ensure they're available
|
||||||
|
|
@ -32,13 +25,13 @@ export DATABASE_PASSWORD
|
||||||
export DATABASE_NAME
|
export DATABASE_NAME
|
||||||
|
|
||||||
# Remove pre-existing puma/passenger server.pid
|
# Remove pre-existing puma/passenger server.pid
|
||||||
rm -f $APP_PATH/tmp/pids/server.pid
|
rm -f "$APP_PATH/tmp/pids/server.pid"
|
||||||
|
|
||||||
# Sync static assets from image to volume
|
# Sync static assets from image to volume
|
||||||
# This ensures new files (like maps_maplibre styles) are copied to the persistent volume
|
# This ensures new and updated files are copied to the persistent volume
|
||||||
if [ -d "/tmp/public_assets" ]; then
|
if [ -d "/tmp/public_assets" ]; then
|
||||||
echo "📦 Syncing new static assets to public volume..."
|
echo "📦 Syncing static assets to public volume..."
|
||||||
cp -rn /tmp/public_assets/* $APP_PATH/public/ 2>/dev/null || true
|
cp -ru /tmp/public_assets/* $APP_PATH/public/ 2>/dev/null || true
|
||||||
echo "✅ Static assets synced!"
|
echo "✅ Static assets synced!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -83,4 +76,4 @@ echo "Running seeds..."
|
||||||
bundle exec rails db:seed
|
bundle exec rails db:seed
|
||||||
|
|
||||||
# run passed commands
|
# run passed commands
|
||||||
bundle exec ${@}
|
exec bundle exec "${@}"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,81 @@ test.describe('Advanced Layers', () => {
|
||||||
|
|
||||||
expect(await fogToggle.isChecked()).toBe(true)
|
expect(await fogToggle.isChecked()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('fog radius setting can be changed and applied', async ({ page }) => {
|
||||||
|
// Enable fog layer first
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const fogToggle = page.locator('label:has-text("Fog of War")').first().locator('input.toggle')
|
||||||
|
await fogToggle.check()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Go to advanced settings tab
|
||||||
|
await page.click('button[data-tab="settings"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Find fog radius slider
|
||||||
|
const fogRadiusSlider = page.locator('input[name="fogOfWarRadius"]')
|
||||||
|
await expect(fogRadiusSlider).toBeVisible()
|
||||||
|
|
||||||
|
// Change the slider value using evaluate to trigger input event
|
||||||
|
await fogRadiusSlider.evaluate((slider) => {
|
||||||
|
slider.value = '500'
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Verify display value updated
|
||||||
|
const displayValue = page.locator('[data-maps--maplibre-target="fogRadiusValue"]')
|
||||||
|
await expect(displayValue).toHaveText('500m')
|
||||||
|
|
||||||
|
// Verify slider value was set
|
||||||
|
expect(await fogRadiusSlider.inputValue()).toBe('500')
|
||||||
|
|
||||||
|
// Click Apply Settings button
|
||||||
|
const applyButton = page.locator('button:has-text("Apply Settings")')
|
||||||
|
await applyButton.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Verify no errors in console
|
||||||
|
const consoleErrors = []
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text())
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
expect(consoleErrors.filter(e => e.includes('fog_layer'))).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fog settings can be applied without errors when fog layer is not visible', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="settings"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Change fog radius slider without enabling fog layer
|
||||||
|
const fogRadiusSlider = page.locator('input[name="fogOfWarRadius"]')
|
||||||
|
await fogRadiusSlider.evaluate((slider) => {
|
||||||
|
slider.value = '750'
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Click Apply Settings - this should not throw an error
|
||||||
|
const applyButton = page.locator('button:has-text("Apply Settings")')
|
||||||
|
await applyButton.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Verify no JavaScript errors occurred
|
||||||
|
const consoleErrors = []
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text())
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
expect(consoleErrors.filter(e => e.includes('undefined') || e.includes('fog'))).toHaveLength(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Scratch Map', () => {
|
test.describe('Scratch Map', () => {
|
||||||
|
|
|
||||||
370
e2e/v2/map/layers/family.spec.js
Normal file
370
e2e/v2/map/layers/family.spec.js
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||||
|
import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete, getMapCenter } from '../../helpers/setup.js'
|
||||||
|
|
||||||
|
test.describe('Family Members Layer', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await navigateToMapsV2(page)
|
||||||
|
await closeOnboardingModal(page)
|
||||||
|
await waitForMapLibre(page)
|
||||||
|
await waitForLoadingComplete(page)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Toggle', () => {
|
||||||
|
test('family members toggle exists in Layers tab', async ({ page }) => {
|
||||||
|
// Open settings panel
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
|
||||||
|
// Click Layers tab
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Check if Family Members toggle exists
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
await expect(familyToggle).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('family members toggle is unchecked by default', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
const isChecked = await familyToggle.isChecked()
|
||||||
|
expect(isChecked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can toggle family members layer on', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
|
||||||
|
// Toggle on
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1000) // Wait for API call and layer update
|
||||||
|
|
||||||
|
const isChecked = await familyToggle.isChecked()
|
||||||
|
expect(isChecked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can toggle family members layer off', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
|
||||||
|
// Toggle on first
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// Then toggle off
|
||||||
|
await familyToggle.uncheck()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
const isChecked = await familyToggle.isChecked()
|
||||||
|
expect(isChecked).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Family Members List', () => {
|
||||||
|
test('family members list is hidden by default', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyMembersList = page.locator('[data-maps--maplibre-target="familyMembersList"]')
|
||||||
|
|
||||||
|
// Should be hidden initially
|
||||||
|
const isHidden = await familyMembersList.evaluate(el => el.style.display === 'none')
|
||||||
|
expect(isHidden).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('family members list appears when toggle is enabled', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
const familyMembersList = page.locator('[data-maps--maplibre-target="familyMembersList"]')
|
||||||
|
|
||||||
|
// Toggle on
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// List should now be visible
|
||||||
|
const isVisible = await familyMembersList.evaluate(el => el.style.display === 'block')
|
||||||
|
expect(isVisible).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('family members list shows members when data exists', async ({ page }) => {
|
||||||
|
// Skip if no family members exist
|
||||||
|
const hasFamilyMembers = await page.evaluate(async () => {
|
||||||
|
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
|
||||||
|
if (!apiKey) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
|
||||||
|
if (!response.ok) return false
|
||||||
|
const data = await response.json()
|
||||||
|
return data.locations && data.locations.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFamilyMembers) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
|
||||||
|
// Toggle on
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1500) // Wait for API call
|
||||||
|
|
||||||
|
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
|
||||||
|
|
||||||
|
// Should have at least one member
|
||||||
|
const memberItems = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]')
|
||||||
|
const count = await memberItems.count()
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('family member item displays email and timestamp', async ({ page }) => {
|
||||||
|
// Skip if no family members exist
|
||||||
|
const hasFamilyMembers = await page.evaluate(async () => {
|
||||||
|
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
|
||||||
|
if (!apiKey) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
|
||||||
|
if (!response.ok) return false
|
||||||
|
const data = await response.json()
|
||||||
|
return data.locations && data.locations.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFamilyMembers) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
|
||||||
|
const firstMember = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]').first()
|
||||||
|
|
||||||
|
// Should have email
|
||||||
|
const emailElement = firstMember.locator('.text-sm.font-medium')
|
||||||
|
await expect(emailElement).toBeVisible()
|
||||||
|
|
||||||
|
// Should have timestamp
|
||||||
|
const timestampElement = firstMember.locator('.text-xs.text-base-content\\/60')
|
||||||
|
await expect(timestampElement).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Center on Member', () => {
|
||||||
|
test('clicking family member centers map on their location', async ({ page }) => {
|
||||||
|
// Skip if no family members exist
|
||||||
|
const hasFamilyMembers = await page.evaluate(async () => {
|
||||||
|
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
|
||||||
|
if (!apiKey) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
|
||||||
|
if (!response.ok) return false
|
||||||
|
const data = await response.json()
|
||||||
|
return data.locations && data.locations.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFamilyMembers) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// Get initial map center
|
||||||
|
const initialCenter = await getMapCenter(page)
|
||||||
|
|
||||||
|
// Click on first family member
|
||||||
|
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
|
||||||
|
const firstMember = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]').first()
|
||||||
|
await firstMember.click()
|
||||||
|
|
||||||
|
// Wait for map animation
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// Get new map center
|
||||||
|
const newCenter = await getMapCenter(page)
|
||||||
|
|
||||||
|
// Map should have moved (centers should be different)
|
||||||
|
const hasMoved = initialCenter.lat !== newCenter.lat || initialCenter.lng !== newCenter.lng
|
||||||
|
expect(hasMoved).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows success toast when centering on member', async ({ page }) => {
|
||||||
|
// Skip if no family members exist
|
||||||
|
const hasFamilyMembers = await page.evaluate(async () => {
|
||||||
|
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
|
||||||
|
if (!apiKey) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
|
||||||
|
if (!response.ok) return false
|
||||||
|
const data = await response.json()
|
||||||
|
return data.locations && data.locations.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFamilyMembers) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// Click on first family member
|
||||||
|
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
|
||||||
|
const firstMember = familyMembersContainer.locator('div[data-action*="centerOnFamilyMember"]').first()
|
||||||
|
await firstMember.click()
|
||||||
|
|
||||||
|
// Wait for toast to appear
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Check for success toast
|
||||||
|
const toast = page.locator('.alert-success, .toast, [role="alert"]').filter({ hasText: 'Centered on family member' })
|
||||||
|
await expect(toast).toBeVisible({ timeout: 3000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Family Layer on Map', () => {
|
||||||
|
test('family layer exists on map', async ({ page }) => {
|
||||||
|
const hasLayer = await page.evaluate(() => {
|
||||||
|
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||||
|
const app = window.Stimulus || window.Application
|
||||||
|
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||||
|
return controller?.map?.getLayer('family') !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hasLayer).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('family layer is hidden by default', async ({ page }) => {
|
||||||
|
const isVisible = await page.evaluate(() => {
|
||||||
|
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||||
|
const app = window.Stimulus || window.Application
|
||||||
|
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||||
|
const visibility = controller?.map?.getLayoutProperty('family', 'visibility')
|
||||||
|
return visibility === 'visible'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(isVisible).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('family layer becomes visible when toggle is enabled', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
const isVisible = await page.evaluate(() => {
|
||||||
|
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||||
|
const app = window.Stimulus || window.Application
|
||||||
|
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||||
|
const visibility = controller?.map?.getLayoutProperty('family', 'visibility')
|
||||||
|
return visibility === 'visible' || visibility === undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(isVisible).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('No Family Members', () => {
|
||||||
|
test('shows appropriate message when no family members are sharing', async ({ page }) => {
|
||||||
|
// This test checks the message when API returns empty array
|
||||||
|
const hasFamilyMembers = await page.evaluate(async () => {
|
||||||
|
const apiKey = document.querySelector('[data-maps--maplibre-api-key-value]')?.dataset.mapsMaplibreApiKeyValue
|
||||||
|
if (!apiKey) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${apiKey}`)
|
||||||
|
if (!response.ok) return false
|
||||||
|
const data = await response.json()
|
||||||
|
return data.locations && data.locations.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only run this test if there are NO family members
|
||||||
|
if (hasFamilyMembers) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const familyToggle = page.locator('label:has-text("Family Members")').first().locator('input.toggle')
|
||||||
|
await familyToggle.check()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
const familyMembersContainer = page.locator('[data-maps--maplibre-target="familyMembersContainer"]')
|
||||||
|
const noMembersMessage = familyMembersContainer.getByText('No family members sharing location')
|
||||||
|
|
||||||
|
await expect(noMembersMessage).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue