mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Merge branch 'dev' into dependabot/bundler/ffaker-2.25.0
This commit is contained in:
commit
3adfcc03c3
108 changed files with 3341 additions and 1622 deletions
|
|
@ -7,7 +7,7 @@ orbs:
|
|||
jobs:
|
||||
test:
|
||||
docker:
|
||||
- image: cimg/ruby:3.4.1-browsers
|
||||
- image: cimg/ruby:3.4.6-browsers
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
CI: true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Base-Image for Ruby and Node.js
|
||||
FROM ruby:3.4.1-alpine
|
||||
FROM ruby:3.4.6-alpine
|
||||
|
||||
ENV APP_PATH=/var/app
|
||||
ENV BUNDLE_VERSION=2.5.21
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4.1'
|
||||
ruby-version: '3.4.6'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.4.1
|
||||
3.4.6
|
||||
|
|
|
|||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [UNRELEASED]
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745
|
||||
- Hexagons for the stats page are now being calculated a lot faster.
|
||||
- Prometheus exporter is now not being started when console is being run.
|
||||
- Stats will now properly reflect countries and cities visited after importing new points.
|
||||
- `GET /api/v1/points will now return correct latitude and longitude values. #1502
|
||||
- Deleting an import will now trigger stats recalculation for affected months. #1789
|
||||
- Importing process should now schedule visits suggestions job a lot faster.
|
||||
- Importing GPX files that start with `<gpx` tag will now be detected correctly. #1775
|
||||
- Buttons on the map now have correct contrast in both light and dark modes.
|
||||
|
||||
## Changed
|
||||
|
||||
- Onboarding modal window now features a link to the App Store and a QR code to configure the Dawarich iOS app.
|
||||
- A permanent option was removed from stats sharing options. Now, stats can be shared for 1, 12 or 24 hours only.
|
||||
- User data archive importing now uploads the file directly to the storage service instead of uploading it to the app first.
|
||||
- Importing progress bars are now looking nice.
|
||||
- Ruby version was updated to 3.4.6.
|
||||
|
||||
## Added
|
||||
|
||||
- [Dawarich Cloud] Based on preferred theme (light or dark), the map page will now load with the corresponding map layer (light or dark).
|
||||
- [Dawarich Cloud] Added foundation for upcoming authentication from iOS app.
|
||||
- [Dawarich Cloud] Trial users can now create up to 5 imports. After that, they will be prompted to subscribe to a paid plan.
|
||||
|
||||
|
||||
# [0.32.0] - 2025-09-13
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -12,6 +12,7 @@ This file contains essential information for Claude to work effectively with the
|
|||
- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos)
|
||||
- Export to GeoJSON and GPX formats
|
||||
- Statistics and analytics (countries visited, distance traveled, etc.)
|
||||
- Public sharing of monthly statistics with time-based expiration
|
||||
- Trips management with photo integration
|
||||
- Areas and visits tracking
|
||||
- Integration with photo management systems (Immich, Photoprism)
|
||||
|
|
@ -75,7 +76,7 @@ This file contains essential information for Claude to work effectively with the
|
|||
- **Trip**: User-defined travel periods with analytics
|
||||
- **Import**: Data import operations
|
||||
- **Export**: Data export operations
|
||||
- **Stat**: Calculated statistics and metrics
|
||||
- **Stat**: Calculated statistics and metrics with public sharing capabilities
|
||||
|
||||
### Geographic Features
|
||||
- Uses PostGIS for advanced geographic queries
|
||||
|
|
@ -126,11 +127,41 @@ npx playwright test # E2E tests
|
|||
- Various import jobs for different data sources
|
||||
- Statistical calculation jobs
|
||||
|
||||
## Public Sharing System
|
||||
|
||||
### Overview
|
||||
Dawarich includes a comprehensive public sharing system that allows users to share their monthly statistics with others without requiring authentication. This feature enables users to showcase their location data while maintaining privacy control through configurable expiration settings.
|
||||
|
||||
### Key Features
|
||||
- **Time-based expiration**: Share links can expire after 1 hour, 12 hours, 24 hours, or be permanent
|
||||
- **UUID-based access**: Each shared stat has a unique, unguessable UUID for security
|
||||
- **Public API endpoints**: Hexagon map data can be accessed via API without authentication when sharing is enabled
|
||||
- **Automatic cleanup**: Expired shares are automatically inaccessible
|
||||
- **Privacy controls**: Users can enable/disable sharing and regenerate sharing URLs at any time
|
||||
|
||||
### Technical Implementation
|
||||
- **Database**: `sharing_settings` (JSONB) and `sharing_uuid` (UUID) columns on `stats` table
|
||||
- **Routes**: `/shared/stats/:uuid` for public viewing, `/stats/:year/:month/sharing` for management
|
||||
- **API**: `/api/v1/maps/hexagons` supports public access via `uuid` parameter
|
||||
- **Controllers**: `Shared::StatsController` handles public views, sharing management integrated into existing stats flow
|
||||
|
||||
### Security Features
|
||||
- **No authentication bypass**: Public sharing only exposes specifically designed endpoints
|
||||
- **UUID-based access**: Sharing URLs use unguessable UUIDs rather than sequential IDs
|
||||
- **Expiration enforcement**: Automatic expiration checking prevents access to expired shares
|
||||
- **Limited data exposure**: Only monthly statistics and hexagon data are publicly accessible
|
||||
|
||||
### Usage Patterns
|
||||
- **Social sharing**: Users can share interesting travel months with friends and family
|
||||
- **Portfolio/showcase**: Travel bloggers and photographers can showcase location statistics
|
||||
- **Data collaboration**: Researchers can share aggregated location data for analysis
|
||||
- **Public demonstrations**: Demo instances can provide public examples without compromising user data
|
||||
|
||||
## API Documentation
|
||||
|
||||
- **Framework**: rSwag (Swagger/OpenAPI)
|
||||
- **Location**: `/api-docs` endpoint
|
||||
- **Authentication**: API key (Bearer) for API access
|
||||
- **Authentication**: API key (Bearer) for API access, UUID-based access for public shares
|
||||
|
||||
## Database Schema
|
||||
|
||||
|
|
@ -142,7 +173,7 @@ npx playwright test # E2E tests
|
|||
- `visits` - Detected area visits
|
||||
- `trips` - Travel periods
|
||||
- `imports`/`exports` - Data transfer operations
|
||||
- `stats` - Calculated metrics
|
||||
- `stats` - Calculated metrics with sharing capabilities (`sharing_settings`, `sharing_uuid`)
|
||||
|
||||
### PostGIS Integration
|
||||
- Extensive use of PostGIS geometry types
|
||||
|
|
@ -201,6 +232,11 @@ bundle exec bundle-audit # Dependency security
|
|||
4. **Testing**: Include both unit and integration tests for location-based features
|
||||
5. **Performance**: Consider database indexes for geographic queries
|
||||
6. **Security**: Never log or expose user location data inappropriately
|
||||
7. **Public Sharing**: When implementing features that interact with stats, consider public sharing access patterns:
|
||||
- Use `public_accessible?` method to check if a stat can be publicly accessed
|
||||
- Support UUID-based access in API endpoints when appropriate
|
||||
- Respect expiration settings and disable sharing when expired
|
||||
- Only expose minimal necessary data in public sharing contexts
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
5
Gemfile
5
Gemfile
|
|
@ -17,6 +17,7 @@ gem 'devise'
|
|||
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
||||
gem 'gpx'
|
||||
gem 'groupdate'
|
||||
gem 'h3', '~> 3.7'
|
||||
gem 'httparty'
|
||||
gem 'importmap-rails'
|
||||
gem 'jwt', '~> 2.8'
|
||||
|
|
@ -38,7 +39,7 @@ gem 'rgeo-geojson'
|
|||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'rswag-api'
|
||||
gem 'rswag-ui'
|
||||
gem 'rubyzip', '~> 2.4'
|
||||
gem 'rubyzip', '~> 3.1'
|
||||
gem 'sentry-rails'
|
||||
gem 'sentry-ruby'
|
||||
gem 'sidekiq'
|
||||
|
|
@ -52,7 +53,7 @@ gem 'tailwindcss-rails'
|
|||
gem 'turbo-rails'
|
||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||
|
||||
group :development, :test do
|
||||
group :development, :test, :staging do
|
||||
gem 'brakeman', require: false
|
||||
gem 'bundler-audit', require: false
|
||||
gem 'debug', platforms: %i[mri mingw x64_mingw]
|
||||
|
|
|
|||
55
Gemfile.lock
55
Gemfile.lock
|
|
@ -107,7 +107,7 @@ GEM
|
|||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.2.3)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
|
|
@ -165,13 +165,19 @@ GEM
|
|||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.4)
|
||||
factory_bot (6.5.5)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
fakeredis (0.1.4)
|
||||
ffaker (2.25.0)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86-linux-gnu)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
fugit (1.11.1)
|
||||
|
|
@ -185,6 +191,10 @@ GEM
|
|||
rake
|
||||
groupdate (6.7.0)
|
||||
activesupport (>= 7.1)
|
||||
h3 (3.7.4)
|
||||
ffi (~> 1.9)
|
||||
rgeo-geojson (~> 2.1)
|
||||
zeitwerk (~> 2.5)
|
||||
hashdiff (1.1.2)
|
||||
httparty (0.23.1)
|
||||
csv
|
||||
|
|
@ -202,7 +212,7 @@ GEM
|
|||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.0)
|
||||
json (2.13.2)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
jwt (2.10.1)
|
||||
|
|
@ -275,16 +285,20 @@ GEM
|
|||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
patience_diff (1.2.0)
|
||||
optimist (~> 3.0)
|
||||
pg (1.5.9)
|
||||
pg (1.6.2)
|
||||
pg (1.6.2-aarch64-linux)
|
||||
pg (1.6.2-arm64-darwin)
|
||||
pg (1.6.2-x86_64-darwin)
|
||||
pg (1.6.2-x86_64-linux)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
prism (1.5.1)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
pry (0.15.2)
|
||||
|
|
@ -305,7 +319,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (3.2.1)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
|
|
@ -354,7 +368,7 @@ GEM
|
|||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
|
|
@ -362,7 +376,7 @@ GEM
|
|||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rgeo (3.0.1)
|
||||
rgeo-activerecord (8.0.0)
|
||||
activerecord (>= 7.0)
|
||||
|
|
@ -402,7 +416,7 @@ GEM
|
|||
rswag-ui (2.16.0)
|
||||
actionpack (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rubocop (1.75.6)
|
||||
rubocop (1.80.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -410,20 +424,20 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-ast (>= 1.46.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
rubocop-ast (1.46.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-rails (2.32.0)
|
||||
rubocop-rails (2.33.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (2.4.1)
|
||||
rubyzip (3.1.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
|
|
@ -492,9 +506,9 @@ GEM
|
|||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
|
|
@ -543,6 +557,7 @@ DEPENDENCIES
|
|||
geocoder!
|
||||
gpx
|
||||
groupdate
|
||||
h3 (~> 3.7)
|
||||
httparty
|
||||
importmap-rails
|
||||
jwt (~> 2.8)
|
||||
|
|
@ -569,7 +584,7 @@ DEPENDENCIES
|
|||
rswag-specs
|
||||
rswag-ui
|
||||
rubocop-rails
|
||||
rubyzip (~> 2.4)
|
||||
rubyzip (~> 3.1)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
|
|
@ -589,7 +604,7 @@ DEPENDENCIES
|
|||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
ruby 3.4.6p54
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
46
app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
Executable file
46
app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
|
||||
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||
</g>
|
||||
<g id="_Group_" data-name="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
|
||||
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_Group_4" data-name="<Group>">
|
||||
<g>
|
||||
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
|
||||
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
|
||||
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
|
||||
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
|
||||
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "Dawarich",
|
||||
"short_name": "Dawarich",
|
||||
"icons": [
|
||||
{
|
||||
"src": "<%= asset_path 'favicon/android-chrome-192x192.png' %>",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "<%= asset_path 'favicon/android-chrome-512x512.png' %>",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
1
app/assets/svg/icons/lucide/outline/goal.svg
Normal file
1
app/assets/svg/icons/lucide/outline/goal.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-goal-icon lucide-goal"><path d="M12 13V2l8 4-8 4"/><path d="M20.561 10.222a9 9 0 1 1-12.55-5.29"/><path d="M8.002 9.997a5 5 0 1 0 8.9 2.02"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
|
|
@ -2,124 +2,85 @@
|
|||
|
||||
class Api::V1::Maps::HexagonsController < ApiController
|
||||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||
before_action :validate_bbox_params, except: [:bounds]
|
||||
before_action :set_user_and_dates
|
||||
|
||||
def index
|
||||
service = Maps::HexagonGrid.new(hexagon_params)
|
||||
result = service.call
|
||||
context = resolve_hexagon_context
|
||||
|
||||
result = Maps::HexagonRequestHandler.new(
|
||||
params: params,
|
||||
user: context[:user] || current_api_user,
|
||||
stat: context[:stat],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
).call
|
||||
|
||||
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
||||
render json: result
|
||||
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||
Maps::HexagonGrid::InvalidCoordinatesError => e
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: "Missing required parameter: #{e.param}" }, status: :bad_request
|
||||
rescue ActionController::BadRequest => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'Shared stats not found or no longer available' }, status: :not_found
|
||||
rescue Stats::CalculateMonth::PostGISError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue Maps::HexagonGrid::PostGISError => e
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
rescue StandardError => _e
|
||||
handle_service_error
|
||||
end
|
||||
|
||||
def bounds
|
||||
# Get the bounding box of user's points for the date range
|
||||
return render json: { error: 'No user found' }, status: :not_found unless @target_user
|
||||
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
||||
context = resolve_hexagon_context
|
||||
|
||||
# Convert dates to timestamps (handle both string and timestamp formats)
|
||||
start_timestamp = case @start_date
|
||||
when String
|
||||
# Check if it's a numeric string (timestamp) or date string
|
||||
if @start_date.match?(/^\d+$/)
|
||||
@start_date.to_i
|
||||
else
|
||||
Time.parse(@start_date).to_i
|
||||
end
|
||||
when Integer
|
||||
@start_date
|
||||
else
|
||||
@start_date.to_i
|
||||
end
|
||||
end_timestamp = case @end_date
|
||||
when String
|
||||
# Check if it's a numeric string (timestamp) or date string
|
||||
if @end_date.match?(/^\d+$/)
|
||||
@end_date.to_i
|
||||
else
|
||||
Time.parse(@end_date).to_i
|
||||
end
|
||||
when Integer
|
||||
@end_date
|
||||
else
|
||||
@end_date.to_i
|
||||
end
|
||||
result = Maps::BoundsCalculator.new(
|
||||
user: context[:user] || context[:target_user],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
).call
|
||||
|
||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||
point_count = points_relation.count
|
||||
|
||||
if point_count.positive?
|
||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3",
|
||||
'bounds_query',
|
||||
[@target_user.id, start_timestamp, end_timestamp]
|
||||
).first
|
||||
|
||||
render json: {
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f,
|
||||
point_count: point_count
|
||||
}
|
||||
if result[:success]
|
||||
render json: result[:data]
|
||||
else
|
||||
render json: {
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
error: result[:error],
|
||||
point_count: result[:point_count]
|
||||
}, status: :not_found
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'Shared stats not found or no longer available' }, status: :not_found
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue Maps::BoundsCalculator::NoUserFoundError => e
|
||||
render json: { error: e.message }, status: :not_found
|
||||
rescue Maps::BoundsCalculator::NoDateRangeError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bbox_params
|
||||
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||
def resolve_hexagon_context
|
||||
return resolve_public_sharing_context if public_sharing_request?
|
||||
|
||||
resolve_authenticated_context
|
||||
end
|
||||
|
||||
def hexagon_params
|
||||
bbox_params.merge(
|
||||
user_id: @target_user&.id,
|
||||
start_date: @start_date,
|
||||
end_date: @end_date
|
||||
)
|
||||
def resolve_public_sharing_context
|
||||
stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
raise ActiveRecord::RecordNotFound unless stat&.public_accessible?
|
||||
|
||||
{
|
||||
user: stat.user,
|
||||
start_date: Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601,
|
||||
end_date: Date.new(stat.year, stat.month, 1).end_of_month.end_of_day.iso8601,
|
||||
stat: stat
|
||||
}
|
||||
end
|
||||
|
||||
def set_user_and_dates
|
||||
return set_public_sharing_context if params[:uuid].present?
|
||||
|
||||
set_authenticated_context
|
||||
end
|
||||
|
||||
def set_public_sharing_context
|
||||
@stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
|
||||
unless @stat&.public_accessible?
|
||||
render json: {
|
||||
error: 'Shared stats not found or no longer available'
|
||||
}, status: :not_found and return
|
||||
end
|
||||
|
||||
@target_user = @stat.user
|
||||
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day.iso8601
|
||||
@end_date = Date.new(@stat.year, @stat.month, 1).end_of_month.end_of_day.iso8601
|
||||
end
|
||||
|
||||
def set_authenticated_context
|
||||
@target_user = current_api_user
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
def resolve_authenticated_context
|
||||
{
|
||||
user: current_api_user,
|
||||
start_date: params[:start_date],
|
||||
end_date: params[:end_date],
|
||||
stat: nil
|
||||
}
|
||||
end
|
||||
|
||||
def handle_service_error
|
||||
|
|
@ -129,15 +90,4 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
def public_sharing_request?
|
||||
params[:uuid].present?
|
||||
end
|
||||
|
||||
def validate_bbox_params
|
||||
required_params = %w[min_lon min_lat max_lon max_lat]
|
||||
missing_params = required_params.select { |param| params[param].blank? }
|
||||
|
||||
return unless missing_params.any?
|
||||
|
||||
render json: {
|
||||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||
}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
||||
|
||||
before_action :unread_notifications, :set_self_hosted_status
|
||||
before_action :unread_notifications, :set_self_hosted_status, :store_client_header
|
||||
|
||||
protected
|
||||
|
||||
|
|
@ -39,12 +39,36 @@ class ApplicationController < ActionController::Base
|
|||
user_not_authorized
|
||||
end
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
# Check both current request header and stored session value
|
||||
client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client]
|
||||
|
||||
case client_type
|
||||
when 'ios'
|
||||
payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i }
|
||||
|
||||
token = Subscription::EncodeJwtToken.new(
|
||||
payload, ENV['AUTH_JWT_SECRET_KEY']
|
||||
).call
|
||||
|
||||
ios_success_path(token:)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_self_hosted_status
|
||||
@self_hosted = DawarichSettings.self_hosted?
|
||||
end
|
||||
|
||||
def store_client_header
|
||||
return unless request.headers['X-Dawarich-Client']
|
||||
|
||||
session[:dawarich_client] = request.headers['X-Dawarich-Client']
|
||||
end
|
||||
|
||||
def user_not_authorized
|
||||
redirect_back fallback_location: root_path,
|
||||
alert: 'You are not authorized to perform this action.',
|
||||
|
|
|
|||
21
app/controllers/auth/ios_controller.rb
Normal file
21
app/controllers/auth/ios_controller.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Auth
|
||||
class IosController < ApplicationController
|
||||
def success
|
||||
# If token is provided, this is the final callback for ASWebAuthenticationSession
|
||||
if params[:token].present?
|
||||
# ASWebAuthenticationSession will capture this URL and extract the token
|
||||
render plain: "Authentication successful! You can close this window.", status: :ok
|
||||
else
|
||||
# This should not happen with our current flow, but keeping for safety
|
||||
render json: {
|
||||
success: true,
|
||||
message: 'iOS authentication successful',
|
||||
redirect_url: root_url
|
||||
}, status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
|
@ -54,21 +54,28 @@ class Settings::UsersController < ApplicationController
|
|||
end
|
||||
|
||||
def import
|
||||
unless params[:archive].present?
|
||||
if params[:archive].blank?
|
||||
redirect_to edit_user_registration_path, alert: 'Please select a ZIP archive to import.'
|
||||
return
|
||||
end
|
||||
|
||||
archive_file = params[:archive]
|
||||
archive_param = params[:archive]
|
||||
|
||||
validate_archive_file(archive_file)
|
||||
# Handle both direct upload (signed_id) and traditional upload (file)
|
||||
if archive_param.is_a?(String)
|
||||
# Direct upload: archive_param is a signed blob ID
|
||||
import = create_import_from_signed_archive_id(archive_param)
|
||||
else
|
||||
# Traditional upload: archive_param is an uploaded file
|
||||
validate_archive_file(archive_param)
|
||||
|
||||
import = current_user.imports.build(
|
||||
name: archive_file.original_filename,
|
||||
name: archive_param.original_filename,
|
||||
source: :user_data_archive
|
||||
)
|
||||
|
||||
import.file.attach(archive_file)
|
||||
import.file.attach(archive_param)
|
||||
end
|
||||
|
||||
if import.save
|
||||
redirect_to edit_user_registration_path,
|
||||
|
|
@ -89,6 +96,36 @@ class Settings::UsersController < ApplicationController
|
|||
params.require(:user).permit(:email, :password)
|
||||
end
|
||||
|
||||
def create_import_from_signed_archive_id(signed_id)
|
||||
Rails.logger.debug "Creating archive import from signed ID: #{signed_id[0..20]}..."
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(signed_id)
|
||||
|
||||
# Validate that it's a ZIP file
|
||||
validate_blob_file_type(blob)
|
||||
|
||||
import_name = generate_unique_import_name(blob.filename.to_s)
|
||||
import = current_user.imports.build(
|
||||
name: import_name,
|
||||
source: :user_data_archive
|
||||
)
|
||||
import.file.attach(blob)
|
||||
|
||||
import
|
||||
end
|
||||
|
||||
def generate_unique_import_name(original_name)
|
||||
return original_name unless current_user.imports.exists?(name: original_name)
|
||||
|
||||
# Extract filename and extension
|
||||
basename = File.basename(original_name, File.extname(original_name))
|
||||
extension = File.extname(original_name)
|
||||
|
||||
# Add current datetime
|
||||
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
|
||||
"#{basename}_#{timestamp}#{extension}"
|
||||
end
|
||||
|
||||
def validate_archive_file(archive_file)
|
||||
unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) ||
|
||||
File.extname(archive_file.original_filename).downcase == '.zip'
|
||||
|
|
@ -96,4 +133,12 @@ class Settings::UsersController < ApplicationController
|
|||
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return
|
||||
end
|
||||
end
|
||||
|
||||
def validate_blob_file_type(blob)
|
||||
unless ['application/zip', 'application/x-zip-compressed'].include?(blob.content_type) ||
|
||||
File.extname(blob.filename.to_s).downcase == '.zip'
|
||||
|
||||
raise StandardError, 'Please upload a valid ZIP file.'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class Shared::StatsController < ApplicationController
|
|||
@user = @stat.user
|
||||
@is_public_view = true
|
||||
@data_bounds = @stat.calculate_data_bounds
|
||||
@hexagons_available = @stat.hexagons_available?
|
||||
|
||||
render 'stats/public_month'
|
||||
end
|
||||
|
|
@ -29,7 +30,7 @@ class Shared::StatsController < ApplicationController
|
|||
return head :not_found unless @stat
|
||||
|
||||
if params[:enabled] == '1'
|
||||
@stat.enable_sharing!(expiration: params[:expiration] || 'permanent')
|
||||
@stat.enable_sharing!(expiration: params[:expiration] || '24h')
|
||||
sharing_url = shared_stat_url(@stat.sharing_uuid)
|
||||
|
||||
render json: {
|
||||
|
|
|
|||
|
|
@ -128,34 +128,45 @@ module StatsHelper
|
|||
def quietest_week(stat)
|
||||
return 'N/A' if stat.daily_distance.empty?
|
||||
|
||||
# Create a hash with date as key and distance as value
|
||||
distance_by_date = stat.daily_distance.to_h.transform_keys do |timestamp|
|
||||
Time.at(timestamp).in_time_zone(stat.user.timezone || 'UTC').to_date
|
||||
end
|
||||
|
||||
# Initialize variables to track the quietest week
|
||||
quietest_start_date = nil
|
||||
quietest_distance = Float::INFINITY
|
||||
|
||||
# Iterate through each day of the month to find the quietest week
|
||||
start_date = distance_by_date.keys.min.beginning_of_month
|
||||
end_date = distance_by_date.keys.max.end_of_month
|
||||
|
||||
(start_date..end_date).each_cons(7) do |week|
|
||||
week_distance = week.sum { |date| distance_by_date[date] || 0 }
|
||||
|
||||
if week_distance < quietest_distance
|
||||
quietest_distance = week_distance
|
||||
quietest_start_date = week.first
|
||||
end
|
||||
end
|
||||
distance_by_date = build_distance_by_date_hash(stat)
|
||||
quietest_start_date = find_quietest_week_start_date(stat, distance_by_date)
|
||||
|
||||
return 'N/A' unless quietest_start_date
|
||||
|
||||
quietest_end_date = quietest_start_date + 6.days
|
||||
start_str = quietest_start_date.strftime('%b %d')
|
||||
end_str = quietest_end_date.strftime('%b %d')
|
||||
format_week_range(quietest_start_date)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_distance_by_date_hash(stat)
|
||||
stat.daily_distance.to_h.transform_keys do |day_number|
|
||||
Date.new(stat.year, stat.month, day_number)
|
||||
end
|
||||
end
|
||||
|
||||
def find_quietest_week_start_date(stat, distance_by_date)
|
||||
quietest_start_date = nil
|
||||
quietest_distance = Float::INFINITY
|
||||
stat_month_start = Date.new(stat.year, stat.month, 1)
|
||||
stat_month_end = stat_month_start.end_of_month
|
||||
|
||||
(stat_month_start..(stat_month_end - 6.days)).each do |start_date|
|
||||
week_dates = (start_date..(start_date + 6.days)).to_a
|
||||
week_distance = week_dates.sum { |date| distance_by_date[date] || 0 }
|
||||
|
||||
if week_distance < quietest_distance
|
||||
quietest_distance = week_distance
|
||||
quietest_start_date = start_date
|
||||
end
|
||||
end
|
||||
|
||||
quietest_start_date
|
||||
end
|
||||
|
||||
def format_week_range(start_date)
|
||||
end_date = start_date + 6.days
|
||||
start_str = start_date.strftime('%b %d')
|
||||
end_str = end_date.strftime('%b %d')
|
||||
"#{start_str} - #{end_str}"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UserHelper
|
||||
def api_key_qr_code(user)
|
||||
def api_key_qr_code(user, size: 6)
|
||||
json = { 'server_url' => root_url, 'api_key' => user.api_key }
|
||||
qrcode = RQRCode::QRCode.new(json.to_json)
|
||||
svg = qrcode.as_svg(
|
||||
color: '000',
|
||||
fill: 'fff',
|
||||
shape_rendering: 'crispEdges',
|
||||
module_size: 6,
|
||||
module_size: size,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
offset: 5
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
import { applyThemeToButton } from "../maps/theme_utils";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [""];
|
||||
static values = {
|
||||
apiKey: String
|
||||
apiKey: String,
|
||||
userTheme: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
|
|
@ -17,12 +19,16 @@ export default class extends Controller {
|
|||
this.currentPopup = null;
|
||||
this.mapsController = null;
|
||||
|
||||
// Listen for theme changes
|
||||
document.addEventListener('theme:changed', this.handleThemeChange.bind(this));
|
||||
|
||||
// Wait for the map to be initialized
|
||||
this.waitForMap();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanup();
|
||||
document.removeEventListener('theme:changed', this.handleThemeChange.bind(this));
|
||||
console.log("Add visit controller disconnected");
|
||||
}
|
||||
|
||||
|
|
@ -76,13 +82,10 @@ export default class extends Controller {
|
|||
button.innerHTML = '➕';
|
||||
button.title = 'Add a visit';
|
||||
|
||||
// Style the button to match other map controls
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userThemeValue || 'dark');
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
|
|
@ -93,19 +96,6 @@ export default class extends Controller {
|
|||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Add hover effects
|
||||
button.addEventListener('mouseenter', () => {
|
||||
if (!this.isAddingVisit) {
|
||||
button.style.backgroundColor = '#f0f0f0';
|
||||
}
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', () => {
|
||||
if (!this.isAddingVisit) {
|
||||
button.style.backgroundColor = 'white';
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle add visit mode on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleAddVisitMode(button);
|
||||
|
|
@ -150,9 +140,8 @@ export default class extends Controller {
|
|||
exitAddVisitMode(button) {
|
||||
this.isAddingVisit = false;
|
||||
|
||||
// Reset button style
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.color = 'black';
|
||||
// Reset button style with theme-aware styling
|
||||
applyThemeToButton(button, this.userThemeValue || 'dark');
|
||||
button.innerHTML = '➕';
|
||||
|
||||
// Reset cursor
|
||||
|
|
@ -446,6 +435,16 @@ export default class extends Controller {
|
|||
});
|
||||
}
|
||||
|
||||
handleThemeChange(event) {
|
||||
console.log('Add visit controller: Theme changed to', event.detail.theme);
|
||||
this.userThemeValue = event.detail.theme;
|
||||
|
||||
// Update button theme if it exists
|
||||
if (this.addVisitButton && !this.isAddingVisit) {
|
||||
applyThemeToButton(this.addVisitButton, this.userThemeValue);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.map) {
|
||||
this.map.off('click', this.onMapClick, this);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export default class extends Controller {
|
|||
static targets = ["input", "progress", "progressBar", "submit", "form"]
|
||||
static values = {
|
||||
url: String,
|
||||
userTrial: Boolean
|
||||
userTrial: Boolean,
|
||||
currentImportsCount: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
|
|
@ -51,6 +52,16 @@ export default class extends Controller {
|
|||
const files = this.inputTarget.files
|
||||
if (files.length === 0) return
|
||||
|
||||
// Check import count limits for trial users
|
||||
if (this.userTrialValue && this.currentImportsCountValue >= 5) {
|
||||
const message = 'Import limit reached. Trial users can only create up to 5 imports. Please subscribe to import more files.'
|
||||
showFlashMessage('error', message)
|
||||
|
||||
// Clear the file input
|
||||
this.inputTarget.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size limits for trial users
|
||||
if (this.userTrialValue) {
|
||||
const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes
|
||||
|
|
@ -82,31 +93,33 @@ export default class extends Controller {
|
|||
this.progressTarget.remove()
|
||||
}
|
||||
|
||||
// Create a wrapper div for better positioning and visibility
|
||||
// Create a wrapper div with better DaisyUI styling
|
||||
const progressWrapper = document.createElement("div")
|
||||
progressWrapper.className = "mt-4 mb-6 border p-4 rounded-lg bg-gray-50"
|
||||
progressWrapper.className = "w-full mt-4 mb-4"
|
||||
|
||||
// Add a label
|
||||
// Add a label with better typography
|
||||
const progressLabel = document.createElement("div")
|
||||
progressLabel.className = "font-medium mb-2 text-gray-700"
|
||||
progressLabel.textContent = "Upload Progress"
|
||||
progressLabel.className = "text-sm font-medium text-base-content mb-2 flex justify-between items-center"
|
||||
progressLabel.innerHTML = `
|
||||
<span>Upload Progress</span>
|
||||
<span class="text-xs text-base-content/70 progress-percentage">0%</span>
|
||||
`
|
||||
progressWrapper.appendChild(progressLabel)
|
||||
|
||||
// Create a new progress container
|
||||
const progressContainer = document.createElement("div")
|
||||
// Create DaisyUI progress element
|
||||
const progressContainer = document.createElement("progress")
|
||||
progressContainer.setAttribute("data-direct-upload-target", "progress")
|
||||
progressContainer.className = "w-full bg-gray-200 rounded-full h-4"
|
||||
progressContainer.className = "progress progress-primary w-full h-3"
|
||||
progressContainer.value = 0
|
||||
progressContainer.max = 100
|
||||
|
||||
// Create the progress bar fill element
|
||||
// Create a hidden div for the progress bar target (for compatibility)
|
||||
const progressBarFill = document.createElement("div")
|
||||
progressBarFill.setAttribute("data-direct-upload-target", "progressBar")
|
||||
progressBarFill.className = "bg-blue-600 h-4 rounded-full transition-all duration-300"
|
||||
progressBarFill.style.width = "0%"
|
||||
progressBarFill.style.display = "none"
|
||||
|
||||
// Add the fill element to the container
|
||||
progressContainer.appendChild(progressBarFill)
|
||||
progressWrapper.appendChild(progressContainer)
|
||||
progressBarFill.dataset.percentageDisplay = "true"
|
||||
progressWrapper.appendChild(progressBarFill)
|
||||
|
||||
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
|
||||
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
||||
|
|
@ -158,6 +171,19 @@ export default class extends Controller {
|
|||
showFlashMessage('error', 'No files were successfully uploaded. Please try again.')
|
||||
} else {
|
||||
showFlashMessage('notice', `${successfulUploads} file(s) uploaded successfully. Ready to submit.`)
|
||||
|
||||
// Add a completion animation to the progress bar
|
||||
const percentageDisplay = this.element.querySelector('.progress-percentage')
|
||||
if (percentageDisplay) {
|
||||
percentageDisplay.textContent = '100%'
|
||||
percentageDisplay.classList.add('text-success')
|
||||
}
|
||||
|
||||
if (this.hasProgressTarget) {
|
||||
this.progressTarget.value = 100
|
||||
this.progressTarget.classList.add('progress-success')
|
||||
this.progressTarget.classList.remove('progress-primary')
|
||||
}
|
||||
}
|
||||
this.isUploading = false
|
||||
console.log("All uploads completed")
|
||||
|
|
@ -169,18 +195,20 @@ export default class extends Controller {
|
|||
|
||||
directUploadWillStoreFileWithXHR(request) {
|
||||
request.upload.addEventListener("progress", event => {
|
||||
if (!this.hasProgressBarTarget) {
|
||||
console.warn("Progress bar target not found")
|
||||
if (!this.hasProgressTarget) {
|
||||
console.warn("Progress target not found")
|
||||
return
|
||||
}
|
||||
|
||||
const progress = (event.loaded / event.total) * 100
|
||||
const progressPercentage = `${progress.toFixed(1)}%`
|
||||
console.log(`Upload progress: ${progressPercentage}`)
|
||||
this.progressBarTarget.style.width = progressPercentage
|
||||
|
||||
// Update text percentage if exists
|
||||
const percentageDisplay = this.element.querySelector('[data-percentage-display="true"]')
|
||||
// Update the DaisyUI progress element
|
||||
this.progressTarget.value = progress
|
||||
|
||||
// Update the percentage display
|
||||
const percentageDisplay = this.element.querySelector('.progress-percentage')
|
||||
if (percentageDisplay) {
|
||||
percentageDisplay.textContent = progressPercentage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fo
|
|||
import { TileMonitor } from "../maps/tile_monitor";
|
||||
import BaseController from "./base_controller";
|
||||
import { createAllMapLayers } from "../maps/layers";
|
||||
import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils";
|
||||
import { injectThemeStyles } from "../maps/theme_styles";
|
||||
|
||||
export default class extends BaseController {
|
||||
static targets = ["container"];
|
||||
|
|
@ -61,6 +63,10 @@ export default class extends BaseController {
|
|||
|
||||
this.apiKey = this.element.dataset.api_key;
|
||||
this.selfHosted = this.element.dataset.self_hosted;
|
||||
this.userTheme = this.element.dataset.user_theme || 'dark';
|
||||
|
||||
// Inject theme styles for Leaflet controls
|
||||
injectThemeStyles(this.userTheme);
|
||||
|
||||
try {
|
||||
this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : [];
|
||||
|
|
@ -134,10 +140,11 @@ export default class extends BaseController {
|
|||
|
||||
const unit = this.distanceUnit === 'km' ? 'km' : 'mi';
|
||||
div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`;
|
||||
div.style.backgroundColor = 'white';
|
||||
div.style.padding = '0 5px';
|
||||
div.style.marginRight = '5px';
|
||||
div.style.display = 'inline-block';
|
||||
applyThemeToControl(div, this.userTheme, {
|
||||
padding: '0 5px',
|
||||
marginRight: '5px',
|
||||
display: 'inline-block'
|
||||
});
|
||||
return div;
|
||||
}
|
||||
});
|
||||
|
|
@ -195,7 +202,7 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
// Initialize the visits manager
|
||||
this.visitsManager = new VisitsManager(this.map, this.apiKey);
|
||||
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme);
|
||||
|
||||
// Expose visits manager globally for location search integration
|
||||
window.visitsManager = this.visitsManager;
|
||||
|
|
@ -257,6 +264,7 @@ export default class extends BaseController {
|
|||
disconnect() {
|
||||
super.disconnect();
|
||||
this.removeEventListeners();
|
||||
|
||||
if (this.tracksSubscription) {
|
||||
this.tracksSubscription.unsubscribe();
|
||||
}
|
||||
|
|
@ -396,40 +404,28 @@ export default class extends BaseController {
|
|||
|
||||
// If this is the preferred layer, add it to the map immediately
|
||||
if (selectedLayerName === this.userSettings.maps.name) {
|
||||
customLayer.addTo(this.map);
|
||||
// Remove any other base layers that might be active
|
||||
// Remove any existing base layers first
|
||||
Object.values(maps).forEach(layer => {
|
||||
if (this.map.hasLayer(layer)) {
|
||||
this.map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
customLayer.addTo(this.map);
|
||||
}
|
||||
|
||||
maps[this.userSettings.maps.name] = customLayer;
|
||||
} else {
|
||||
// If no custom map is set, ensure a default layer is added
|
||||
// First check if maps object has any entries
|
||||
// If no maps were created (fallback case), add OSM
|
||||
if (Object.keys(maps).length === 0) {
|
||||
// Fallback to OSM if no maps are configured
|
||||
maps["OpenStreetMap"] = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
console.warn('No map layers available, adding OSM fallback');
|
||||
const osmLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
|
||||
});
|
||||
osmLayer.addTo(this.map);
|
||||
maps["OpenStreetMap"] = osmLayer;
|
||||
}
|
||||
|
||||
// Now try to get the selected layer or fall back to alternatives
|
||||
const defaultLayer = maps[selectedLayerName] || Object.values(maps)[0];
|
||||
|
||||
if (defaultLayer) {
|
||||
defaultLayer.addTo(this.map);
|
||||
} else {
|
||||
console.error("Could not find any default map layer");
|
||||
// Ultimate fallback - create and add OSM layer directly
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
|
||||
}).addTo(this.map);
|
||||
}
|
||||
// Note: createAllMapLayers already added the user's preferred layer to the map
|
||||
}
|
||||
|
||||
return maps;
|
||||
|
|
@ -731,13 +727,10 @@ export default class extends BaseController {
|
|||
const button = L.DomUtil.create('button', 'map-settings-button');
|
||||
button.innerHTML = '⚙️'; // Gear icon
|
||||
|
||||
// Style the button
|
||||
button.style.backgroundColor = 'white';
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '32px';
|
||||
button.style.height = '32px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
|
@ -863,11 +856,9 @@ export default class extends BaseController {
|
|||
</form>
|
||||
`;
|
||||
|
||||
// Style the panel
|
||||
div.style.backgroundColor = 'white';
|
||||
// Style the panel with theme-aware styling
|
||||
applyThemeToPanel(div, this.userTheme);
|
||||
div.style.padding = '10px';
|
||||
div.style.border = '1px solid #ccc';
|
||||
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
|
||||
// Prevent map interactions when interacting with the form
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
|
|
@ -1010,6 +1001,17 @@ export default class extends BaseController {
|
|||
const mapElement = document.getElementById('map');
|
||||
if (mapElement) {
|
||||
mapElement.setAttribute('data-user_settings', JSON.stringify(this.userSettings));
|
||||
// Update theme if it changed
|
||||
if (newSettings.theme && newSettings.theme !== this.userTheme) {
|
||||
this.userTheme = newSettings.theme;
|
||||
mapElement.setAttribute('data-user_theme', this.userTheme);
|
||||
injectThemeStyles(this.userTheme);
|
||||
|
||||
// Dispatch theme change event for other controllers
|
||||
document.dispatchEvent(new CustomEvent('theme:changed', {
|
||||
detail: { theme: this.userTheme }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Store current layer states
|
||||
|
|
@ -1091,12 +1093,10 @@ export default class extends BaseController {
|
|||
const button = L.DomUtil.create('button', 'toggle-panel-button');
|
||||
button.innerHTML = '📅';
|
||||
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, controller.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
|
|
@ -1131,12 +1131,12 @@ export default class extends BaseController {
|
|||
const RouteTracksControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const container = L.DomUtil.create('div', 'routes-tracks-selector leaflet-bar');
|
||||
container.style.backgroundColor = 'white';
|
||||
container.style.padding = '8px';
|
||||
container.style.borderRadius = '4px';
|
||||
container.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
container.style.fontSize = '12px';
|
||||
container.style.lineHeight = '1.2';
|
||||
applyThemeToControl(container, controller.userTheme, {
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.2'
|
||||
});
|
||||
|
||||
// Get saved preference or default to 'routes'
|
||||
const savedPreference = localStorage.getItem('mapRouteMode') || 'routes';
|
||||
|
|
@ -1395,10 +1395,8 @@ export default class extends BaseController {
|
|||
|
||||
this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths);
|
||||
|
||||
div.style.backgroundColor = 'white';
|
||||
applyThemeToPanel(div, this.userTheme);
|
||||
div.style.padding = '10px';
|
||||
div.style.border = '1px solid #ccc';
|
||||
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
div.style.marginRight = '10px';
|
||||
div.style.marginTop = '10px';
|
||||
div.style.width = '300px';
|
||||
|
|
@ -1840,7 +1838,7 @@ export default class extends BaseController {
|
|||
|
||||
initializeLocationSearch() {
|
||||
if (this.map && this.apiKey && this.features.reverse_geocoding) {
|
||||
this.locationSearch = new LocationSearch(this.map, this.apiKey);
|
||||
this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import L from "leaflet";
|
||||
import { createHexagonGrid } from "../maps/hexagon_grid";
|
||||
import { createAllMapLayers } from "../maps/layers";
|
||||
import BaseController from "./base_controller";
|
||||
|
||||
|
|
@ -10,6 +9,7 @@ export default class extends BaseController {
|
|||
month: Number,
|
||||
uuid: String,
|
||||
dataBounds: Object,
|
||||
hexagonsAvailable: Boolean,
|
||||
selfHosted: String
|
||||
};
|
||||
|
||||
|
|
@ -17,14 +17,12 @@ export default class extends BaseController {
|
|||
super.connect();
|
||||
console.log('🏁 Controller connected - loading overlay should be visible');
|
||||
this.selfHosted = this.selfHostedValue || 'false';
|
||||
this.currentHexagonLayer = null;
|
||||
this.initializeMap();
|
||||
this.loadHexagons();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.hexagonGrid) {
|
||||
this.hexagonGrid.destroy();
|
||||
}
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
|
|
@ -44,15 +42,15 @@ export default class extends BaseController {
|
|||
// Add dynamic tile layer based on self-hosted setting
|
||||
this.addMapLayers();
|
||||
|
||||
// Default view
|
||||
this.map.setView([40.0, -100.0], 4);
|
||||
// Default view with higher zoom level for better hexagon detail
|
||||
this.map.setView([40.0, -100.0], 9);
|
||||
}
|
||||
|
||||
addMapLayers() {
|
||||
try {
|
||||
// Use appropriate default layer based on self-hosted mode
|
||||
const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
|
||||
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
|
||||
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted, 'dark');
|
||||
|
||||
// If no layers were created, fall back to OSM
|
||||
if (Object.keys(maps).length === 0) {
|
||||
|
|
@ -101,34 +99,23 @@ export default class extends BaseController {
|
|||
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
|
||||
}
|
||||
|
||||
this.hexagonGrid = createHexagonGrid(this.map, {
|
||||
apiEndpoint: '/api/v1/maps/hexagons',
|
||||
style: {
|
||||
fillColor: '#3388ff',
|
||||
fillOpacity: 0.3,
|
||||
color: '#3388ff',
|
||||
weight: 1,
|
||||
opacity: 0.7
|
||||
},
|
||||
debounceDelay: 300,
|
||||
maxZoom: 15,
|
||||
minZoom: 4
|
||||
});
|
||||
console.log('🎯 Public sharing: using manual hexagon loading');
|
||||
console.log('🔍 Debug values:');
|
||||
console.log(' dataBounds:', dataBounds);
|
||||
console.log(' point_count:', dataBounds?.point_count);
|
||||
console.log(' hexagonsAvailableValue:', this.hexagonsAvailableValue);
|
||||
console.log(' hexagonsAvailableValue type:', typeof this.hexagonsAvailableValue);
|
||||
|
||||
// Force hide immediately after creation to prevent auto-showing
|
||||
this.hexagonGrid.hide();
|
||||
|
||||
// Disable all dynamic behavior by removing event listeners
|
||||
this.map.off('moveend');
|
||||
this.map.off('zoomend');
|
||||
|
||||
// Load hexagons only once on page load (static behavior)
|
||||
// NOTE: Do NOT hide loading overlay here - let loadStaticHexagons() handle it
|
||||
if (dataBounds && dataBounds.point_count > 0) {
|
||||
// Load hexagons only if they are pre-calculated and data exists
|
||||
if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) {
|
||||
await this.loadStaticHexagons();
|
||||
} else {
|
||||
console.warn('No data bounds or points available - not showing hexagons');
|
||||
// Only hide loading indicator if no hexagons to load
|
||||
if (!this.hexagonsAvailableValue) {
|
||||
console.log('📋 No pre-calculated hexagons available for public sharing - skipping hexagon loading');
|
||||
} else {
|
||||
console.warn('⚠️ No data bounds or points available - not showing hexagons');
|
||||
}
|
||||
// Hide loading indicator if no hexagons to load
|
||||
const loadingElement = document.getElementById('map-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
|
|
@ -186,7 +173,6 @@ export default class extends BaseController {
|
|||
min_lat: dataBounds.min_lat,
|
||||
max_lon: dataBounds.max_lng,
|
||||
max_lat: dataBounds.max_lat,
|
||||
hex_size: 1000, // Fixed 1km hexagons
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate.toISOString(),
|
||||
uuid: this.uuidValue
|
||||
|
|
@ -237,6 +223,11 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
addStaticHexagonsToMap(geojsonData) {
|
||||
// Remove existing hexagon layer if it exists
|
||||
if (this.currentHexagonLayer) {
|
||||
this.map.removeLayer(this.currentHexagonLayer);
|
||||
}
|
||||
|
||||
// Calculate max point count for color scaling
|
||||
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
|
||||
|
||||
|
|
@ -256,6 +247,7 @@ export default class extends BaseController {
|
|||
}
|
||||
});
|
||||
|
||||
this.currentHexagonLayer = staticHexagonLayer;
|
||||
staticHexagonLayer.addTo(this.map);
|
||||
}
|
||||
|
||||
|
|
@ -272,11 +264,31 @@ export default class extends BaseController {
|
|||
buildPopupContent(props) {
|
||||
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
|
||||
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
|
||||
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : '';
|
||||
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : '';
|
||||
|
||||
return `
|
||||
<div style="font-size: 12px; line-height: 1.4;">
|
||||
<strong>Date Range:</strong><br>
|
||||
<small>${startDate} - ${endDate}</small>
|
||||
<div style="font-size: 12px; line-height: 1.6; max-width: 300px;">
|
||||
<strong style="color: #3388ff;">📍 Location Data</strong><br>
|
||||
<div style="margin: 4px 0;">
|
||||
<strong>Points:</strong> ${props.point_count || 0}
|
||||
</div>
|
||||
${props.h3_index ? `
|
||||
<div style="margin: 4px 0;">
|
||||
<strong>H3 Index:</strong><br>
|
||||
<code style="font-size: 10px; background: #f5f5f5; padding: 2px;">${props.h3_index}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin: 4px 0;">
|
||||
<strong>Time Range:</strong><br>
|
||||
<small>${startDate} ${startTime}<br>→ ${endDate} ${endTime}</small>
|
||||
</div>
|
||||
${props.center ? `
|
||||
<div style="margin: 4px 0;">
|
||||
<strong>Center:</strong><br>
|
||||
<small>${props.center[0].toFixed(6)}, ${props.center[1].toFixed(6)}</small>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ export default class extends BaseController {
|
|||
try {
|
||||
// Use appropriate default layer based on self-hosted mode
|
||||
const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
|
||||
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
|
||||
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted, 'dark');
|
||||
|
||||
// If no layers were created, fall back to OSM
|
||||
if (Object.keys(maps).length === 0) {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default class extends BaseController {
|
|||
this.userSettingsValue.preferred_map_layer || "OpenStreetMap" :
|
||||
"OpenStreetMap";
|
||||
|
||||
let maps = createAllMapLayers(this.map, selectedLayerName);
|
||||
let maps = createAllMapLayers(this.map, selectedLayerName, "false", 'dark');
|
||||
|
||||
// Add custom map if it exists in settings
|
||||
if (this.hasUserSettingsValue && this.userSettingsValue.maps && this.userSettingsValue.maps.url) {
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export default class extends BaseController {
|
|||
|
||||
baseMaps() {
|
||||
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
|
||||
let maps = createAllMapLayers(this.map, selectedLayerName);
|
||||
let maps = createAllMapLayers(this.map, selectedLayerName, "false", 'dark');
|
||||
|
||||
// Add custom map if it exists in settings
|
||||
if (this.userSettings.maps && this.userSettings.maps.url) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import { DirectUpload } from "@rails/activestorage"
|
||||
import { showFlashMessage } from "../maps/helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "progress", "progressBar", "submit", "form"]
|
||||
static values = {
|
||||
url: String,
|
||||
userTrial: Boolean
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.inputTarget.addEventListener("change", this.upload.bind(this))
|
||||
|
||||
// Add form submission handler to disable the file input
|
||||
if (this.hasFormTarget) {
|
||||
this.formTarget.addEventListener("submit", this.onSubmit.bind(this))
|
||||
}
|
||||
|
||||
// Initially disable submit button if no files are uploaded
|
||||
if (this.hasSubmitTarget) {
|
||||
const hasUploadedFiles = this.element.querySelectorAll('input[name="archive"][type="hidden"]').length > 0
|
||||
this.submitTarget.disabled = !hasUploadedFiles
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(event) {
|
||||
if (this.isUploading) {
|
||||
// If still uploading, prevent submission
|
||||
event.preventDefault()
|
||||
console.log("Form submission prevented during upload")
|
||||
return
|
||||
}
|
||||
|
||||
// Disable the file input to prevent it from being submitted with the form
|
||||
// This ensures only our hidden input with signed ID is submitted
|
||||
this.inputTarget.disabled = true
|
||||
|
||||
// Check if we have a signed ID
|
||||
const signedId = this.element.querySelector('input[name="archive"][type="hidden"]')
|
||||
if (!signedId) {
|
||||
event.preventDefault()
|
||||
console.log("No file uploaded yet")
|
||||
alert("Please select and upload a ZIP archive first")
|
||||
} else {
|
||||
console.log("Submitting form with uploaded archive")
|
||||
}
|
||||
}
|
||||
|
||||
upload() {
|
||||
const files = this.inputTarget.files
|
||||
if (files.length === 0) return
|
||||
|
||||
const file = files[0] // Only handle single file for archives
|
||||
|
||||
// Validate file type
|
||||
if (!this.isValidZipFile(file)) {
|
||||
showFlashMessage('error', 'Please select a valid ZIP file.')
|
||||
this.inputTarget.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size limits for trial users
|
||||
if (this.userTrialValue) {
|
||||
const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const message = `File size limit exceeded. Trial users can only upload files up to 10MB. File size: ${(file.size / 1024 / 1024).toFixed(1)}MB`
|
||||
showFlashMessage('error', message)
|
||||
|
||||
// Clear the file input
|
||||
this.inputTarget.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Uploading archive: ${file.name}`)
|
||||
this.isUploading = true
|
||||
|
||||
// Disable submit button during upload
|
||||
this.submitTarget.disabled = true
|
||||
this.submitTarget.classList.add("opacity-50", "cursor-not-allowed")
|
||||
|
||||
// Show uploading message using flash
|
||||
showFlashMessage('notice', `Uploading ${file.name}, please wait...`)
|
||||
|
||||
// Always remove any existing progress bar to ensure we create a fresh one
|
||||
if (this.hasProgressTarget) {
|
||||
this.progressTarget.remove()
|
||||
}
|
||||
|
||||
// Create a wrapper div with better DaisyUI styling
|
||||
const progressWrapper = document.createElement("div")
|
||||
progressWrapper.className = "w-full mt-4 mb-4"
|
||||
|
||||
// Add a label with better typography
|
||||
const progressLabel = document.createElement("div")
|
||||
progressLabel.className = "text-sm font-medium text-base-content mb-2 flex justify-between items-center"
|
||||
progressLabel.innerHTML = `
|
||||
<span>Upload Progress</span>
|
||||
<span class="text-xs text-base-content/70 progress-percentage">0%</span>
|
||||
`
|
||||
progressWrapper.appendChild(progressLabel)
|
||||
|
||||
// Create DaisyUI progress element
|
||||
const progressContainer = document.createElement("progress")
|
||||
progressContainer.setAttribute("data-user-data-archive-direct-upload-target", "progress")
|
||||
progressContainer.className = "progress progress-primary w-full h-3"
|
||||
progressContainer.value = 0
|
||||
progressContainer.max = 100
|
||||
|
||||
// Create a hidden div for the progress bar target (for compatibility)
|
||||
const progressBarFill = document.createElement("div")
|
||||
progressBarFill.setAttribute("data-user-data-archive-direct-upload-target", "progressBar")
|
||||
progressBarFill.style.display = "none"
|
||||
|
||||
progressWrapper.appendChild(progressContainer)
|
||||
progressWrapper.appendChild(progressBarFill)
|
||||
|
||||
// Add the progress wrapper after the form-control div containing the file input
|
||||
const formControl = this.inputTarget.closest('.form-control')
|
||||
if (formControl) {
|
||||
formControl.parentNode.insertBefore(progressWrapper, formControl.nextSibling)
|
||||
} else {
|
||||
// Fallback: insert before submit button
|
||||
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
||||
}
|
||||
|
||||
console.log("Progress bar created and inserted after file input")
|
||||
|
||||
// Clear any existing hidden field for archive
|
||||
const existingHiddenField = this.element.querySelector('input[name="archive"][type="hidden"]')
|
||||
if (existingHiddenField) {
|
||||
existingHiddenField.remove()
|
||||
}
|
||||
|
||||
const upload = new DirectUpload(file, this.urlValue, this)
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
console.error("Error uploading file:", error)
|
||||
// Show error to user using flash
|
||||
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
||||
|
||||
// Re-enable submit button but keep it disabled since no file was uploaded
|
||||
this.submitTarget.disabled = true
|
||||
this.submitTarget.classList.add("opacity-50", "cursor-not-allowed")
|
||||
} else {
|
||||
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
|
||||
|
||||
// Create a hidden field with the correct name
|
||||
const hiddenField = document.createElement("input")
|
||||
hiddenField.setAttribute("type", "hidden")
|
||||
hiddenField.setAttribute("name", "archive")
|
||||
hiddenField.setAttribute("value", blob.signed_id)
|
||||
this.element.appendChild(hiddenField)
|
||||
|
||||
console.log("Added hidden field with signed ID:", blob.signed_id)
|
||||
|
||||
// Enable submit button
|
||||
this.submitTarget.disabled = false
|
||||
this.submitTarget.classList.remove("opacity-50", "cursor-not-allowed")
|
||||
|
||||
showFlashMessage('notice', `Archive uploaded successfully. Ready to import.`)
|
||||
|
||||
// Add a completion animation to the progress bar
|
||||
const percentageDisplay = this.element.querySelector('.progress-percentage')
|
||||
if (percentageDisplay) {
|
||||
percentageDisplay.textContent = '100%'
|
||||
percentageDisplay.classList.add('text-success')
|
||||
}
|
||||
|
||||
if (this.hasProgressTarget) {
|
||||
this.progressTarget.value = 100
|
||||
this.progressTarget.classList.add('progress-success')
|
||||
this.progressTarget.classList.remove('progress-primary')
|
||||
}
|
||||
}
|
||||
|
||||
this.isUploading = false
|
||||
console.log("Upload completed")
|
||||
})
|
||||
}
|
||||
|
||||
isValidZipFile(file) {
|
||||
// Check MIME type
|
||||
const validMimeTypes = ['application/zip', 'application/x-zip-compressed']
|
||||
if (validMimeTypes.includes(file.type)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check file extension as fallback
|
||||
const filename = file.name.toLowerCase()
|
||||
return filename.endsWith('.zip')
|
||||
}
|
||||
|
||||
directUploadWillStoreFileWithXHR(request) {
|
||||
request.upload.addEventListener("progress", event => {
|
||||
if (!this.hasProgressTarget) {
|
||||
console.warn("Progress target not found")
|
||||
return
|
||||
}
|
||||
|
||||
const progress = (event.loaded / event.total) * 100
|
||||
const progressPercentage = `${progress.toFixed(1)}%`
|
||||
console.log(`Upload progress: ${progressPercentage}`)
|
||||
|
||||
// Update the DaisyUI progress element
|
||||
this.progressTarget.value = progress
|
||||
|
||||
// Update the percentage display
|
||||
const percentageDisplay = this.element.querySelector('.progress-percentage')
|
||||
if (percentageDisplay) {
|
||||
percentageDisplay.textContent = progressPercentage
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
/**
|
||||
* HexagonGrid - Manages hexagonal grid overlay on Leaflet maps
|
||||
* Provides efficient loading and rendering of hexagon tiles based on viewport
|
||||
*/
|
||||
export class HexagonGrid {
|
||||
constructor(map, options = {}) {
|
||||
this.map = map;
|
||||
this.options = {
|
||||
apiEndpoint: '/api/v1/maps/hexagons',
|
||||
style: {
|
||||
fillColor: '#3388ff',
|
||||
fillOpacity: 0.1,
|
||||
color: '#3388ff',
|
||||
weight: 1,
|
||||
opacity: 0.5
|
||||
},
|
||||
debounceDelay: 300, // ms to wait before loading new hexagons
|
||||
maxZoom: 18, // Don't show hexagons beyond this zoom level
|
||||
minZoom: 8, // Don't show hexagons below this zoom level
|
||||
...options
|
||||
};
|
||||
|
||||
this.hexagonLayer = null;
|
||||
this.loadingController = null; // For aborting requests
|
||||
this.lastBounds = null;
|
||||
this.isVisible = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Create the hexagon layer group
|
||||
this.hexagonLayer = L.layerGroup();
|
||||
|
||||
// Bind map events
|
||||
this.map.on('moveend', this.debounce(this.onMapMove.bind(this), this.options.debounceDelay));
|
||||
this.map.on('zoomend', this.onZoomChange.bind(this));
|
||||
|
||||
// Initial load if within zoom range
|
||||
if (this.shouldShowHexagons()) {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the hexagon grid overlay
|
||||
*/
|
||||
show() {
|
||||
if (!this.isVisible) {
|
||||
this.isVisible = true;
|
||||
if (this.shouldShowHexagons()) {
|
||||
this.hexagonLayer.addTo(this.map);
|
||||
this.loadHexagons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the hexagon grid overlay
|
||||
*/
|
||||
hide() {
|
||||
if (this.isVisible) {
|
||||
this.isVisible = false;
|
||||
this.hexagonLayer.remove();
|
||||
this.cancelPendingRequest();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of hexagon grid
|
||||
*/
|
||||
toggle() {
|
||||
if (this.isVisible) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hexagons should be displayed at current zoom level
|
||||
*/
|
||||
shouldShowHexagons() {
|
||||
const zoom = this.map.getZoom();
|
||||
return zoom >= this.options.minZoom && zoom <= this.options.maxZoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle map move events
|
||||
*/
|
||||
onMapMove() {
|
||||
if (!this.isVisible || !this.shouldShowHexagons()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBounds = this.map.getBounds();
|
||||
|
||||
// Only reload if bounds have changed significantly
|
||||
if (this.boundsChanged(currentBounds)) {
|
||||
this.loadHexagons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle zoom change events
|
||||
*/
|
||||
onZoomChange() {
|
||||
if (!this.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldShowHexagons()) {
|
||||
// Show hexagons and load for new zoom level
|
||||
if (!this.map.hasLayer(this.hexagonLayer)) {
|
||||
this.hexagonLayer.addTo(this.map);
|
||||
}
|
||||
this.loadHexagons();
|
||||
} else {
|
||||
// Hide hexagons when zoomed too far in/out
|
||||
this.hexagonLayer.remove();
|
||||
this.cancelPendingRequest();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bounds have changed enough to warrant reloading
|
||||
*/
|
||||
boundsChanged(newBounds) {
|
||||
if (!this.lastBounds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const threshold = 0.1; // 10% change threshold
|
||||
const oldArea = this.getBoundsArea(this.lastBounds);
|
||||
const newArea = this.getBoundsArea(newBounds);
|
||||
const intersection = this.getBoundsIntersection(this.lastBounds, newBounds);
|
||||
const intersectionRatio = intersection / Math.min(oldArea, newArea);
|
||||
|
||||
return intersectionRatio < (1 - threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate approximate area of bounds
|
||||
*/
|
||||
getBoundsArea(bounds) {
|
||||
const sw = bounds.getSouthWest();
|
||||
const ne = bounds.getNorthEast();
|
||||
return (ne.lat - sw.lat) * (ne.lng - sw.lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intersection area between two bounds
|
||||
*/
|
||||
getBoundsIntersection(bounds1, bounds2) {
|
||||
const sw1 = bounds1.getSouthWest();
|
||||
const ne1 = bounds1.getNorthEast();
|
||||
const sw2 = bounds2.getSouthWest();
|
||||
const ne2 = bounds2.getNorthEast();
|
||||
|
||||
const left = Math.max(sw1.lng, sw2.lng);
|
||||
const right = Math.min(ne1.lng, ne2.lng);
|
||||
const bottom = Math.max(sw1.lat, sw2.lat);
|
||||
const top = Math.min(ne1.lat, ne2.lat);
|
||||
|
||||
if (left < right && bottom < top) {
|
||||
return (right - left) * (top - bottom);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load hexagons for current viewport
|
||||
*/
|
||||
async loadHexagons() {
|
||||
console.log('❌ Using ORIGINAL loadHexagons method (should not happen for public sharing)');
|
||||
|
||||
// Cancel any pending request
|
||||
this.cancelPendingRequest();
|
||||
|
||||
const bounds = this.map.getBounds();
|
||||
this.lastBounds = bounds;
|
||||
|
||||
// Create new AbortController for this request
|
||||
this.loadingController = new AbortController();
|
||||
|
||||
try {
|
||||
// Get current date range from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at');
|
||||
const endDate = urlParams.get('end_at');
|
||||
|
||||
// Get viewport dimensions
|
||||
const mapContainer = this.map.getContainer();
|
||||
const viewportWidth = mapContainer.offsetWidth;
|
||||
const viewportHeight = mapContainer.offsetHeight;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
min_lon: bounds.getWest(),
|
||||
min_lat: bounds.getSouth(),
|
||||
max_lon: bounds.getEast(),
|
||||
max_lat: bounds.getNorth(),
|
||||
viewport_width: viewportWidth,
|
||||
viewport_height: viewportHeight
|
||||
});
|
||||
|
||||
// Add date parameters if they exist
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const response = await fetch(`${this.options.apiEndpoint}?${params}`, {
|
||||
signal: this.loadingController.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const geojsonData = await response.json();
|
||||
|
||||
// Clear existing hexagons and add new ones
|
||||
this.clearHexagons();
|
||||
this.addHexagonsToMap(geojsonData);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Failed to load hexagons:', error);
|
||||
// Optionally show user-friendly error message
|
||||
}
|
||||
} finally {
|
||||
this.loadingController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending hexagon loading request
|
||||
*/
|
||||
cancelPendingRequest() {
|
||||
if (this.loadingController) {
|
||||
this.loadingController.abort();
|
||||
this.loadingController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear existing hexagons from the map
|
||||
*/
|
||||
clearHexagons() {
|
||||
this.hexagonLayer.clearLayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add hexagons to the map from GeoJSON data
|
||||
*/
|
||||
addHexagonsToMap(geojsonData) {
|
||||
if (!geojsonData.features || geojsonData.features.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate max point count for color scaling
|
||||
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
|
||||
|
||||
const geoJsonLayer = L.geoJSON(geojsonData, {
|
||||
style: (feature) => this.styleHexagonByData(feature, maxPoints),
|
||||
onEachFeature: (feature, layer) => {
|
||||
// Add popup with statistics
|
||||
const props = feature.properties;
|
||||
const popupContent = this.buildPopupContent(props);
|
||||
layer.bindPopup(popupContent);
|
||||
}
|
||||
});
|
||||
|
||||
geoJsonLayer.addTo(this.hexagonLayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Style hexagon based on point density and other data
|
||||
*/
|
||||
styleHexagonByData(feature, maxPoints) {
|
||||
const props = feature.properties;
|
||||
const pointCount = props.point_count || 0;
|
||||
|
||||
// Calculate opacity based on point density (0.2 to 0.8)
|
||||
const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
|
||||
|
||||
let color = '#3388ff'
|
||||
|
||||
return {
|
||||
fillColor: color,
|
||||
fillOpacity: opacity,
|
||||
color: color,
|
||||
weight: 1,
|
||||
opacity: opacity + 0.2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build popup content with hexagon statistics
|
||||
*/
|
||||
buildPopupContent(props) {
|
||||
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
|
||||
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
|
||||
|
||||
return `
|
||||
<div style="font-size: 12px; line-height: 1.4;">
|
||||
<strong>Date Range:</strong><br>
|
||||
<small>${startDate} - ${endDate}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hexagon style
|
||||
*/
|
||||
updateStyle(newStyle) {
|
||||
this.options.style = { ...this.options.style, ...newStyle };
|
||||
|
||||
// Update existing hexagons
|
||||
this.hexagonLayer.eachLayer((layer) => {
|
||||
if (layer.setStyle) {
|
||||
layer.setStyle(this.options.style);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the hexagon grid and clean up
|
||||
*/
|
||||
destroy() {
|
||||
this.hide();
|
||||
this.map.off('moveend');
|
||||
this.map.off('zoomend');
|
||||
this.hexagonLayer = null;
|
||||
this.lastBounds = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple debounce utility
|
||||
*/
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return a new HexagonGrid instance
|
||||
*/
|
||||
export function createHexagonGrid(map, options = {}) {
|
||||
return new HexagonGrid(map, options);
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default HexagonGrid;
|
||||
|
|
@ -49,8 +49,11 @@ export function createMapLayer(map, selectedLayerName, layerKey, selfHosted) {
|
|||
export function createAllMapLayers(map, selectedLayerName, selfHosted) {
|
||||
const layers = {};
|
||||
const mapsConfig = selfHosted === "true" ? rasterMapsConfig : vectorMapsConfig;
|
||||
|
||||
Object.keys(mapsConfig).forEach(layerKey => {
|
||||
layers[layerKey] = createMapLayer(map, selectedLayerName, layerKey, selfHosted);
|
||||
// Create the layer and add it to the map if it's the user's selected layer
|
||||
const layer = createMapLayer(map, selectedLayerName, layerKey, selfHosted);
|
||||
layers[layerKey] = layer;
|
||||
});
|
||||
|
||||
return layers;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
// Location search functionality for the map
|
||||
import { applyThemeToButton } from "./theme_utils";
|
||||
|
||||
class LocationSearch {
|
||||
constructor(map, apiKey) {
|
||||
constructor(map, apiKey, userTheme = 'dark') {
|
||||
this.map = map;
|
||||
this.apiKey = apiKey;
|
||||
this.userTheme = userTheme;
|
||||
this.searchResults = [];
|
||||
this.searchMarkersLayer = null;
|
||||
this.currentSearchQuery = '';
|
||||
|
|
@ -22,12 +25,10 @@ class LocationSearch {
|
|||
onAdd: function(map) {
|
||||
const button = L.DomUtil.create('button', 'location-search-toggle');
|
||||
button.innerHTML = '🔍';
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.fontSize = '18px';
|
||||
|
|
@ -1158,6 +1159,7 @@ class LocationSearch {
|
|||
return new Date(dateString).toLocaleDateString() + ' ' +
|
||||
new Date(dateString).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { LocationSearch };
|
||||
|
|
|
|||
156
app/javascript/maps/theme_styles.js
Normal file
156
app/javascript/maps/theme_styles.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Dynamic CSS injection for theme-aware Leaflet controls
|
||||
export function injectThemeStyles(userTheme) {
|
||||
// Remove existing theme styles if any
|
||||
const existingStyle = document.getElementById('leaflet-theme-styles');
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
const themeColors = getThemeColors(userTheme);
|
||||
|
||||
const css = `
|
||||
/* Leaflet default controls theme override */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-zoom,
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle,
|
||||
.leaflet-control-layers-list,
|
||||
.leaflet-control-draw {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
border-color: ${themeColors.borderColor} !important;
|
||||
box-shadow: 0 1px 4px ${themeColors.shadowColor} !important;
|
||||
}
|
||||
|
||||
/* Leaflet zoom buttons */
|
||||
.leaflet-control-zoom a {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
border-bottom: 1px solid ${themeColors.borderColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background-color: ${themeColors.hoverColor} !important;
|
||||
}
|
||||
|
||||
/* Leaflet layer control */
|
||||
.leaflet-control-layers-toggle {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-expanded {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers label {
|
||||
color: ${themeColors.textColor} !important;
|
||||
}
|
||||
|
||||
/* Leaflet Draw controls */
|
||||
.leaflet-draw-toolbar a {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
border-bottom: 1px solid ${themeColors.borderColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-draw-toolbar a:hover {
|
||||
background-color: ${themeColors.hoverColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-draw-actions a {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
}
|
||||
|
||||
/* Leaflet popups */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
}
|
||||
|
||||
/* Attribution control */
|
||||
.leaflet-control-attribution a {
|
||||
color: ${userTheme === 'light' ? '#0066cc' : '#66b3ff'} !important;
|
||||
}
|
||||
|
||||
/* Custom control buttons */
|
||||
.leaflet-control-button,
|
||||
.add-visit-button,
|
||||
.leaflet-bar button {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
border: 1px solid ${themeColors.borderColor} !important;
|
||||
box-shadow: 0 1px 4px ${themeColors.shadowColor} !important;
|
||||
}
|
||||
|
||||
.leaflet-control-button:hover,
|
||||
.add-visit-button:hover,
|
||||
.leaflet-bar button:hover {
|
||||
background-color: ${themeColors.hoverColor} !important;
|
||||
}
|
||||
|
||||
/* Any other custom controls */
|
||||
.leaflet-top .leaflet-control button,
|
||||
.leaflet-bottom .leaflet-control button,
|
||||
.leaflet-left .leaflet-control button,
|
||||
.leaflet-right .leaflet-control button {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
border: 1px solid ${themeColors.borderColor} !important;
|
||||
}
|
||||
|
||||
/* Location search button */
|
||||
.location-search-toggle,
|
||||
#location-search-toggle {
|
||||
background-color: ${themeColors.backgroundColor} !important;
|
||||
color: ${themeColors.textColor} !important;
|
||||
border: 1px solid ${themeColors.borderColor} !important;
|
||||
box-shadow: 0 1px 4px ${themeColors.shadowColor} !important;
|
||||
}
|
||||
|
||||
.location-search-toggle:hover,
|
||||
#location-search-toggle:hover {
|
||||
background-color: ${themeColors.hoverColor} !important;
|
||||
}
|
||||
|
||||
/* Distance scale control - minimal theming to avoid duplication */
|
||||
.leaflet-control-scale {
|
||||
background: rgba(${userTheme === 'light' ? '255, 255, 255' : '55, 65, 81'}, 0.9) !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject the CSS
|
||||
const style = document.createElement('style');
|
||||
style.id = 'leaflet-theme-styles';
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getThemeColors(userTheme) {
|
||||
if (userTheme === 'light') {
|
||||
return {
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#000000',
|
||||
borderColor: '#e5e7eb',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
hoverColor: '#f3f4f6'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
backgroundColor: '#374151',
|
||||
textColor: '#ffffff',
|
||||
borderColor: '#4b5563',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
hoverColor: '#4b5563'
|
||||
};
|
||||
}
|
||||
}
|
||||
79
app/javascript/maps/theme_utils.js
Normal file
79
app/javascript/maps/theme_utils.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Theme utility functions for map controls and buttons
|
||||
|
||||
/**
|
||||
* Get theme-aware styles for map controls based on user theme
|
||||
* @param {string} userTheme - 'light' or 'dark'
|
||||
* @returns {Object} Object containing CSS properties for the theme
|
||||
*/
|
||||
export function getThemeStyles(userTheme) {
|
||||
if (userTheme === 'light') {
|
||||
return {
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#000000',
|
||||
borderColor: '#e5e7eb',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
backgroundColor: '#374151',
|
||||
color: '#ffffff',
|
||||
borderColor: '#4b5563',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme-aware styles to a control element
|
||||
* @param {HTMLElement} element - DOM element to style
|
||||
* @param {string} userTheme - 'light' or 'dark'
|
||||
* @param {Object} additionalStyles - Optional additional CSS properties
|
||||
*/
|
||||
export function applyThemeToControl(element, userTheme, additionalStyles = {}) {
|
||||
const themeStyles = getThemeStyles(userTheme);
|
||||
|
||||
// Apply base theme styles
|
||||
element.style.backgroundColor = themeStyles.backgroundColor;
|
||||
element.style.color = themeStyles.color;
|
||||
element.style.border = `1px solid ${themeStyles.borderColor}`;
|
||||
element.style.boxShadow = `0 1px 4px ${themeStyles.shadowColor}`;
|
||||
|
||||
// Apply any additional styles
|
||||
Object.assign(element.style, additionalStyles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme-aware styles to a button element
|
||||
* @param {HTMLElement} button - Button element to style
|
||||
* @param {string} userTheme - 'light' or 'dark'
|
||||
*/
|
||||
export function applyThemeToButton(button, userTheme) {
|
||||
applyThemeToControl(button, userTheme, {
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
});
|
||||
|
||||
// Add hover effects
|
||||
const themeStyles = getThemeStyles(userTheme);
|
||||
const hoverBg = userTheme === 'light' ? '#f3f4f6' : '#4b5563';
|
||||
|
||||
button.addEventListener('mouseenter', () => {
|
||||
button.style.backgroundColor = hoverBg;
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', () => {
|
||||
button.style.backgroundColor = themeStyles.backgroundColor;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme-aware styles to a panel/container element
|
||||
* @param {HTMLElement} panel - Panel element to style
|
||||
* @param {string} userTheme - 'light' or 'dark'
|
||||
*/
|
||||
export function applyThemeToPanel(panel, userTheme) {
|
||||
applyThemeToControl(panel, userTheme, {
|
||||
borderRadius: '4px'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import L from "leaflet";
|
||||
import { showFlashMessage } from "./helpers";
|
||||
import { applyThemeToButton } from "./theme_utils";
|
||||
|
||||
/**
|
||||
* Manages visits functionality including displaying, fetching, and interacting with visits
|
||||
*/
|
||||
export class VisitsManager {
|
||||
constructor(map, apiKey) {
|
||||
constructor(map, apiKey, userTheme = 'dark') {
|
||||
this.map = map;
|
||||
this.apiKey = apiKey;
|
||||
this.userTheme = userTheme;
|
||||
|
||||
// Create custom panes for different visit types
|
||||
if (!map.getPane('confirmedVisitsPane')) {
|
||||
|
|
@ -67,12 +69,10 @@ export class VisitsManager {
|
|||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button');
|
||||
button.innerHTML = '⬅️'; // Left arrow icon
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
|
|
@ -104,12 +104,10 @@ export class VisitsManager {
|
|||
button.innerHTML = '⚓️';
|
||||
button.title = 'Select Area';
|
||||
button.id = 'selection-tool-button';
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
|
|
|
|||
6
app/javascript/posthog.js
Normal file
6
app/javascript/posthog.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
!function(t,e){var o,n,p,r;e.__SV||(window.posthog && window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init Ce Ds js Te Os As capture Ye calculateEventProperties Us register register_once register_for_session unregister unregister_for_session Hs getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey displaySurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty qs Ns createPersonProfile Bs Cs Ws opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing get_explicit_consent_status is_capturing clear_opt_in_out_capturing Ls debug L zs getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('phc_X0Rqns0y8Nbjcfcye0sq9EcVmKC5AX6589mjH7n9lR1', {
|
||||
api_host: 'https://eu.i.posthog.com',
|
||||
defaults: '2025-05-24',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
})
|
||||
13
app/jobs/points/nightly_reverse_geocoding_job.rb
Normal file
13
app/jobs/points/nightly_reverse_geocoding_job.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Points::NightlyReverseGeocodingJob < ApplicationJob
|
||||
queue_as :reverse_geocoding
|
||||
|
||||
def perform
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point|
|
||||
point.async_reverse_geocode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -11,9 +11,11 @@ class Import < ApplicationRecord
|
|||
|
||||
after_commit -> { Import::ProcessJob.perform_later(id) unless skip_background_processing }, on: :create
|
||||
after_commit :remove_attached_file, on: :destroy
|
||||
before_commit :recalculate_stats, on: :destroy, if: -> { points.exists? }
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
validate :file_size_within_limit, if: -> { user.trial? }
|
||||
validate :import_count_within_limit, if: -> { user.trial? }
|
||||
|
||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
|
|
@ -63,8 +65,23 @@ class Import < ApplicationRecord
|
|||
def file_size_within_limit
|
||||
return unless file.attached?
|
||||
|
||||
if file.blob.byte_size > 11.megabytes
|
||||
return unless file.blob.byte_size > 11.megabytes
|
||||
|
||||
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
|
||||
end
|
||||
|
||||
def import_count_within_limit
|
||||
return unless new_record?
|
||||
|
||||
existing_imports_count = user.imports.count
|
||||
return unless existing_imports_count >= 5
|
||||
|
||||
errors.add(:base, 'Trial users can only create up to 5 imports. Please subscribe to import more files.')
|
||||
end
|
||||
|
||||
def recalculate_stats
|
||||
years_and_months_tracked.each do |year, month|
|
||||
Stats::CalculatingJob.perform_later(user.id, year, month)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class Stat < ApplicationRecord
|
|||
|
||||
def sharing_expired?
|
||||
expiration = sharing_settings['expiration']
|
||||
return false if expiration.blank? || expiration == 'permanent'
|
||||
return false if expiration.blank?
|
||||
|
||||
expires_at_value = sharing_settings['expires_at']
|
||||
return true if expires_at_value.blank?
|
||||
|
|
@ -56,11 +56,20 @@ class Stat < ApplicationRecord
|
|||
sharing_enabled? && !sharing_expired?
|
||||
end
|
||||
|
||||
def hexagons_available?
|
||||
h3_hex_ids.present? &&
|
||||
(h3_hex_ids.is_a?(Hash) || h3_hex_ids.is_a?(Array)) &&
|
||||
h3_hex_ids.any?
|
||||
end
|
||||
|
||||
def generate_new_sharing_uuid!
|
||||
update!(sharing_uuid: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def enable_sharing!(expiration: '1h')
|
||||
# Default to 24h if an invalid expiration is provided
|
||||
expiration = '24h' unless %w[1h 12h 24h].include?(expiration)
|
||||
|
||||
expires_at = case expiration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
|
|
@ -71,7 +80,7 @@ class Stat < ApplicationRecord
|
|||
sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => expiration,
|
||||
'expires_at' => expires_at&.iso8601
|
||||
'expires_at' => expires_at.iso8601
|
||||
},
|
||||
sharing_uuid: sharing_uuid || SecureRandom.uuid
|
||||
)
|
||||
|
|
@ -116,6 +125,10 @@ class Stat < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
def process!
|
||||
Stats::CalculatingJob.perform_later(user.id, year, month)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_sharing_uuid
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HexagonQuery
|
||||
# Maximum number of hexagons to return in a single request
|
||||
MAX_HEXAGONS_PER_REQUEST = 5000
|
||||
|
||||
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
|
||||
|
||||
def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil)
|
||||
@min_lon = min_lon
|
||||
@min_lat = min_lat
|
||||
@max_lon = max_lon
|
||||
@max_lat = max_lat
|
||||
@hex_size = hex_size
|
||||
@user_id = user_id
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
binds = []
|
||||
user_sql = build_user_filter(binds)
|
||||
date_filter = build_date_filter(binds)
|
||||
|
||||
sql = build_hexagon_sql(user_sql, date_filter)
|
||||
|
||||
ActiveRecord::Base.connection.exec_query(sql, 'hexagon_sql', binds)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_hexagon_sql(user_sql, date_filter)
|
||||
<<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT
|
||||
ST_Transform(geom, 3857) as geom_utm,
|
||||
geom as geom_wgs84
|
||||
FROM bbox_geom
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
id,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE #{user_sql}
|
||||
#{date_filter}
|
||||
AND ST_Intersects(
|
||||
lonlat,
|
||||
(SELECT geom FROM bbox_geom)::geometry
|
||||
)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).j as hex_j
|
||||
FROM bbox_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hex_geom_utm,
|
||||
hex_i,
|
||||
hex_j
|
||||
FROM hex_grid hg
|
||||
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_stats AS (
|
||||
SELECT
|
||||
hwp.hex_geom_utm,
|
||||
hwp.hex_i,
|
||||
hwp.hex_j,
|
||||
COUNT(up.id) as point_count,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
|
||||
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
|
||||
)
|
||||
SELECT
|
||||
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||
hex_i,
|
||||
hex_j,
|
||||
point_count,
|
||||
earliest_point,
|
||||
latest_point,
|
||||
row_number() OVER (ORDER BY point_count DESC) as id
|
||||
FROM hexagon_stats
|
||||
ORDER BY point_count DESC
|
||||
LIMIT $6;
|
||||
SQL
|
||||
end
|
||||
|
||||
def build_user_filter(binds)
|
||||
# Add bbox coordinates: min_lon, min_lat, max_lon, max_lat
|
||||
binds << min_lon
|
||||
binds << min_lat
|
||||
binds << max_lon
|
||||
binds << max_lat
|
||||
|
||||
# Add hex_size
|
||||
binds << hex_size
|
||||
|
||||
# Add limit
|
||||
binds << MAX_HEXAGONS_PER_REQUEST
|
||||
|
||||
if user_id
|
||||
binds << user_id
|
||||
'user_id = $7'
|
||||
else
|
||||
'1=1'
|
||||
end
|
||||
end
|
||||
|
||||
def build_date_filter(binds)
|
||||
return '' unless start_date || end_date
|
||||
|
||||
conditions = []
|
||||
current_param_index = user_id ? 8 : 7 # Account for bbox, hex_size, limit, and potential user_id
|
||||
|
||||
if start_date
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
binds << start_timestamp
|
||||
conditions << "timestamp >= $#{current_param_index}"
|
||||
current_param_index += 1
|
||||
end
|
||||
|
||||
if end_date
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
binds << end_timestamp
|
||||
conditions << "timestamp <= $#{current_param_index}"
|
||||
end
|
||||
|
||||
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
|
||||
end
|
||||
|
||||
def parse_date_to_timestamp(date_string)
|
||||
# Convert ISO date string to timestamp integer
|
||||
Time.parse(date_string).to_i
|
||||
rescue ArgumentError => e
|
||||
ExceptionReporter.call(e, "Invalid date format: #{date_string}")
|
||||
raise ArgumentError, "Invalid date format: #{date_string}"
|
||||
end
|
||||
end
|
||||
|
|
@ -1,9 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::PointSerializer < PointSerializer
|
||||
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze
|
||||
class Api::PointSerializer
|
||||
EXCLUDED_ATTRIBUTES = %w[
|
||||
created_at updated_at visit_id import_id user_id raw_data
|
||||
country_id
|
||||
].freeze
|
||||
|
||||
def initialize(point)
|
||||
@point = point
|
||||
end
|
||||
|
||||
def call
|
||||
point.attributes.except(*EXCLUDED_ATTRIBUTES)
|
||||
point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes|
|
||||
lat = point.lat
|
||||
lon = point.lon
|
||||
|
||||
attributes['latitude'] = lat.nil? ? nil : lat.to_s
|
||||
attributes['longitude'] = lon.nil? ? nil : lon.to_s
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :point
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ class Api::UserSerializer
|
|||
end
|
||||
|
||||
def call
|
||||
{
|
||||
data = {
|
||||
user: {
|
||||
email: user.email,
|
||||
theme: user.theme,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
settings: settings,
|
||||
settings: settings
|
||||
}
|
||||
}
|
||||
|
||||
data.merge!(subscription: subscription) unless DawarichSettings.self_hosted?
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -41,4 +45,11 @@ class Api::UserSerializer
|
|||
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold
|
||||
}
|
||||
end
|
||||
|
||||
def subscription
|
||||
{
|
||||
status: user.status,
|
||||
active_until: user.active_until
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -78,12 +78,11 @@ class Imports::Create
|
|||
def schedule_visit_suggesting(user_id, import)
|
||||
return unless user.safe_settings.visits_suggestions_enabled?
|
||||
|
||||
points = import.points.order(:timestamp)
|
||||
min_max = import.points.pluck('MIN(timestamp), MAX(timestamp)').first
|
||||
return if min_max.compact.empty?
|
||||
|
||||
return if points.none?
|
||||
|
||||
start_at = Time.zone.at(points.first.timestamp)
|
||||
end_at = Time.zone.at(points.last.timestamp)
|
||||
start_at = Time.zone.at(min_max[0])
|
||||
end_at = Time.zone.at(min_max[1])
|
||||
|
||||
VisitSuggestingJob.perform_later(user_id:, start_at:, end_at:)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class Imports::SourceDetector
|
|||
]
|
||||
},
|
||||
geojson: {
|
||||
required_keys: ['type', 'features'],
|
||||
required_keys: %w[type features],
|
||||
required_values: { 'type' => 'FeatureCollection' },
|
||||
nested_patterns: [
|
||||
['features', 0, 'type'],
|
||||
|
|
@ -79,9 +79,7 @@ class Imports::SourceDetector
|
|||
DETECTION_RULES.each do |format, rules|
|
||||
next if format == :owntracks # Already handled above
|
||||
|
||||
if matches_format?(json_data, rules)
|
||||
return format
|
||||
end
|
||||
return format if matches_format?(json_data, rules)
|
||||
end
|
||||
|
||||
nil
|
||||
|
|
@ -105,14 +103,17 @@ class Imports::SourceDetector
|
|||
return false unless filename.downcase.end_with?('.gpx')
|
||||
|
||||
# Check content for GPX structure
|
||||
content_to_check = if file_path && File.exist?(file_path)
|
||||
content_to_check =
|
||||
if file_path && File.exist?(file_path)
|
||||
# Read first 1KB for GPX detection
|
||||
File.open(file_path, 'rb') { |f| f.read(1024) }
|
||||
else
|
||||
file_content
|
||||
end
|
||||
|
||||
content_to_check.strip.start_with?('<?xml') && content_to_check.include?('<gpx')
|
||||
(
|
||||
content_to_check.strip.start_with?('<?xml') ||
|
||||
content_to_check.strip.start_with?('<gpx')
|
||||
) && content_to_check.include?('<gpx')
|
||||
end
|
||||
|
||||
def owntracks_file?
|
||||
|
|
@ -169,19 +170,13 @@ class Imports::SourceDetector
|
|||
return false unless structure_matches?(json_data, pattern[:structure])
|
||||
|
||||
# Check required keys
|
||||
if pattern[:required_keys]
|
||||
return false unless has_required_keys?(json_data, pattern[:required_keys])
|
||||
end
|
||||
return false if pattern[:required_keys] && !has_required_keys?(json_data, pattern[:required_keys])
|
||||
|
||||
# Check required values
|
||||
if pattern[:required_values]
|
||||
return false unless has_required_values?(json_data, pattern[:required_values])
|
||||
end
|
||||
return false if pattern[:required_values] && !has_required_values?(json_data, pattern[:required_values])
|
||||
|
||||
# Check nested patterns
|
||||
if pattern[:nested_patterns]
|
||||
return false unless has_nested_patterns?(json_data, pattern[:nested_patterns])
|
||||
end
|
||||
return false if pattern[:nested_patterns] && !has_nested_patterns?(json_data, pattern[:nested_patterns])
|
||||
|
||||
true
|
||||
end
|
||||
|
|
@ -221,9 +216,11 @@ class Imports::SourceDetector
|
|||
|
||||
if current.is_a?(Array)
|
||||
return false if key >= current.length
|
||||
|
||||
current = current[key]
|
||||
elsif current.is_a?(Hash)
|
||||
return false unless current.key?(key)
|
||||
|
||||
current = current[key]
|
||||
else
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ module LocationSearch
|
|||
last_point = sorted_points.last
|
||||
|
||||
# Calculate visit duration
|
||||
duration_minutes = if sorted_points.length > 1
|
||||
duration_minutes =
|
||||
if sorted_points.length > 1
|
||||
((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round
|
||||
else
|
||||
# Single point visit - estimate based on typical stay time
|
||||
|
|
@ -56,7 +57,7 @@ module LocationSearch
|
|||
end
|
||||
|
||||
# Find the most accurate point (lowest accuracy value means higher precision)
|
||||
most_accurate_point = points.min_by { |p| p[:accuracy] || 999999 }
|
||||
most_accurate_point = points.min_by { |p| p[:accuracy] || 999_999 }
|
||||
|
||||
# Calculate average distance from search center
|
||||
average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2)
|
||||
|
|
@ -86,7 +87,7 @@ module LocationSearch
|
|||
hours = minutes / 60
|
||||
remaining_minutes = minutes % 60
|
||||
|
||||
if remaining_minutes == 0
|
||||
if remaining_minutes.zero?
|
||||
"~#{pluralize(hours, 'hour')}"
|
||||
else
|
||||
"~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}"
|
||||
|
|
|
|||
94
app/services/maps/bounds_calculator.rb
Normal file
94
app/services/maps/bounds_calculator.rb
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class BoundsCalculator
|
||||
class NoUserFoundError < StandardError; end
|
||||
class NoDateRangeError < StandardError; end
|
||||
|
||||
def initialize(user:, start_date:, end_date:)
|
||||
@user = user
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
validate_inputs!
|
||||
|
||||
start_timestamp = parse_date_parameter(@start_date)
|
||||
end_timestamp = parse_date_parameter(@end_date)
|
||||
|
||||
point_count =
|
||||
@user
|
||||
.points
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:id)
|
||||
.count
|
||||
|
||||
return build_no_data_response if point_count.zero?
|
||||
|
||||
bounds_result = execute_bounds_query(start_timestamp, end_timestamp)
|
||||
build_success_response(bounds_result, point_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_inputs!
|
||||
raise NoUserFoundError, 'No user found' unless @user
|
||||
raise NoDateRangeError, 'No date range specified' unless @start_date && @end_date
|
||||
end
|
||||
|
||||
def execute_bounds_query(start_timestamp, end_timestamp)
|
||||
ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3",
|
||||
'bounds_query',
|
||||
[@user.id, start_timestamp, end_timestamp]
|
||||
).first
|
||||
end
|
||||
|
||||
def build_success_response(bounds_result, point_count)
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f,
|
||||
point_count: point_count
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_no_data_response
|
||||
{
|
||||
success: false,
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
}
|
||||
end
|
||||
|
||||
def parse_date_parameter(param)
|
||||
case param
|
||||
when String
|
||||
if param.match?(/^\d+$/)
|
||||
param.to_i
|
||||
else
|
||||
parsed_time = Time.zone.parse(param)
|
||||
raise ArgumentError, "Invalid date format: #{param}" if parsed_time.nil?
|
||||
|
||||
parsed_time.to_i
|
||||
end
|
||||
when Integer
|
||||
param
|
||||
else
|
||||
param.to_i
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
|
||||
raise ArgumentError, "Invalid date format: #{param}"
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/services/maps/date_parameter_coercer.rb
Normal file
39
app/services/maps/date_parameter_coercer.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class DateParameterCoercer
|
||||
class InvalidDateFormatError < StandardError; end
|
||||
|
||||
def initialize(param)
|
||||
@param = param
|
||||
end
|
||||
|
||||
def call
|
||||
coerce_date(@param)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :param
|
||||
|
||||
def coerce_date(param)
|
||||
case param
|
||||
when String
|
||||
coerce_string_param(param)
|
||||
when Integer
|
||||
param
|
||||
else
|
||||
param.to_i
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
|
||||
raise InvalidDateFormatError, "Invalid date format: #{param}"
|
||||
end
|
||||
|
||||
def coerce_string_param(param)
|
||||
return param.to_i if param.match?(/^\d+$/)
|
||||
|
||||
Time.parse(param).to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
89
app/services/maps/hexagon_center_manager.rb
Normal file
89
app/services/maps/hexagon_center_manager.rb
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class HexagonCenterManager
|
||||
def initialize(stat:, user:)
|
||||
@stat = stat
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
return build_response_from_centers if pre_calculated_centers_available?
|
||||
|
||||
nil # No pre-calculated data available
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :stat, :user
|
||||
|
||||
def pre_calculated_centers_available?
|
||||
return false if stat&.h3_hex_ids.blank?
|
||||
|
||||
stat.h3_hex_ids.is_a?(Array) && stat.h3_hex_ids.any?
|
||||
end
|
||||
|
||||
def build_response_from_centers
|
||||
hex_ids = stat.h3_hex_ids
|
||||
Rails.logger.debug "Using pre-calculated H3 hex IDs: #{hex_ids.size} hexagons"
|
||||
|
||||
result = build_hexagons_from_h3_ids(hex_ids)
|
||||
{ success: true, data: result, pre_calculated: true }
|
||||
end
|
||||
|
||||
def recalculate_h3_hex_ids
|
||||
service = Stats::CalculateMonth.new(user.id, stat.year, stat.month)
|
||||
service.send(:calculate_h3_hex_ids)
|
||||
end
|
||||
|
||||
def update_stat_with_new_hex_ids(new_hex_ids)
|
||||
stat.update(h3_hex_ids: new_hex_ids)
|
||||
result = build_hexagons_from_h3_ids(new_hex_ids)
|
||||
Rails.logger.debug "Successfully recalculated H3 hex IDs: #{new_hex_ids.size} hexagons"
|
||||
{ success: true, data: result, pre_calculated: true }
|
||||
end
|
||||
|
||||
def build_hexagons_from_h3_ids(hex_ids)
|
||||
# Convert stored H3 IDs back to hexagon polygons
|
||||
# Array format: [[h3_index, point_count, earliest, latest], ...]
|
||||
hexagon_features = hex_ids.map.with_index do |row, index|
|
||||
h3_index, count, earliest, latest = row
|
||||
build_hexagon_feature_from_h3(h3_index, [count, earliest, latest], index)
|
||||
end
|
||||
|
||||
build_feature_collection(hexagon_features)
|
||||
end
|
||||
|
||||
def build_hexagon_feature_from_h3(h3_index, data, index)
|
||||
count, earliest, latest = data
|
||||
|
||||
{
|
||||
'type' => 'Feature',
|
||||
'id' => index + 1,
|
||||
'geometry' => Maps::HexagonPolygonGenerator.new(h3_index:).call,
|
||||
'properties' => build_hexagon_properties(index, count, earliest, latest)
|
||||
}
|
||||
end
|
||||
|
||||
def build_hexagon_properties(index, count, earliest, latest)
|
||||
{
|
||||
'hex_id' => index + 1,
|
||||
'point_count' => count,
|
||||
'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil,
|
||||
'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil
|
||||
}
|
||||
end
|
||||
|
||||
def build_feature_collection(hexagon_features)
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => hexagon_features,
|
||||
'metadata' => {
|
||||
'count' => hexagon_features.count,
|
||||
'user_id' => user.id,
|
||||
'pre_calculated' => true
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Maps::HexagonGrid
|
||||
include ActiveModel::Validations
|
||||
|
||||
# Constants for configuration
|
||||
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
|
||||
MAX_AREA_KM2 = 250_000 # 500km x 500km
|
||||
|
||||
# Validation error classes
|
||||
class BoundingBoxTooLargeError < StandardError; end
|
||||
class InvalidCoordinatesError < StandardError; end
|
||||
class PostGISError < StandardError; end
|
||||
|
||||
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date, :viewport_width,
|
||||
:viewport_height
|
||||
|
||||
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
|
||||
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
|
||||
validates :hex_size, numericality: { greater_than: 0 }
|
||||
|
||||
validate :validate_bbox_order
|
||||
validate :validate_area_size
|
||||
|
||||
def initialize(params = {})
|
||||
@min_lon = params[:min_lon].to_f
|
||||
@min_lat = params[:min_lat].to_f
|
||||
@max_lon = params[:max_lon].to_f
|
||||
@max_lat = params[:max_lat].to_f
|
||||
@hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
|
||||
@viewport_width = params[:viewport_width]&.to_f
|
||||
@viewport_height = params[:viewport_height]&.to_f
|
||||
@user_id = params[:user_id]
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
end
|
||||
|
||||
def call
|
||||
validate!
|
||||
|
||||
generate_hexagons
|
||||
end
|
||||
|
||||
def area_km2
|
||||
@area_km2 ||= calculate_area_km2
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_area_km2
|
||||
width = (max_lon - min_lon).abs
|
||||
height = (max_lat - min_lat).abs
|
||||
|
||||
# Convert degrees to approximate kilometers
|
||||
# 1 degree latitude ≈ 111 km
|
||||
# 1 degree longitude ≈ 111 km * cos(latitude)
|
||||
avg_lat = (min_lat + max_lat) / 2
|
||||
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
|
||||
height_km = height * 111
|
||||
|
||||
width_km * height_km
|
||||
end
|
||||
|
||||
def validate_bbox_order
|
||||
errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
|
||||
errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
|
||||
end
|
||||
|
||||
def validate_area_size
|
||||
return unless area_km2 > MAX_AREA_KM2
|
||||
|
||||
errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
|
||||
end
|
||||
|
||||
def generate_hexagons
|
||||
query = HexagonQuery.new(
|
||||
min_lon:, min_lat:, max_lon:, max_lat:,
|
||||
hex_size:, user_id:, start_date:, end_date:
|
||||
)
|
||||
|
||||
result = query.call
|
||||
|
||||
format_hexagons(result)
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
message = "Failed to generate hexagon grid: #{e.message}"
|
||||
|
||||
ExceptionReporter.call(e, message)
|
||||
raise PostGISError, message
|
||||
end
|
||||
|
||||
def format_hexagons(result)
|
||||
total_points = 0
|
||||
|
||||
hexagons = result.map do |row|
|
||||
point_count = row['point_count'].to_i
|
||||
total_points += point_count
|
||||
|
||||
# Parse timestamps and format dates
|
||||
earliest = row['earliest_point'] ? Time.zone.at(row['earliest_point'].to_f).iso8601 : nil
|
||||
latest = row['latest_point'] ? Time.zone.at(row['latest_point'].to_f).iso8601 : nil
|
||||
|
||||
{
|
||||
type: 'Feature',
|
||||
id: row['id'],
|
||||
geometry: JSON.parse(row['geojson']),
|
||||
properties: {
|
||||
hex_id: row['id'],
|
||||
hex_i: row['hex_i'],
|
||||
hex_j: row['hex_j'],
|
||||
hex_size: hex_size,
|
||||
point_count: point_count,
|
||||
earliest_point: earliest,
|
||||
latest_point: latest
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => hexagons,
|
||||
'metadata' => {
|
||||
'bbox' => [min_lon, min_lat, max_lon, max_lat],
|
||||
'area_km2' => area_km2.round(2),
|
||||
'hex_size_m' => hex_size,
|
||||
'count' => hexagons.count,
|
||||
'total_points' => total_points,
|
||||
'user_id' => user_id,
|
||||
'date_range' => build_date_range_metadata
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_date_range_metadata
|
||||
return nil unless start_date || end_date
|
||||
|
||||
{ 'start_date' => start_date, 'end_date' => end_date }
|
||||
end
|
||||
|
||||
def validate!
|
||||
return if valid?
|
||||
|
||||
raise BoundingBoxTooLargeError, errors.full_messages.join(', ') if area_km2 > MAX_AREA_KM2
|
||||
|
||||
raise InvalidCoordinatesError, errors.full_messages.join(', ')
|
||||
end
|
||||
|
||||
def viewport_valid?
|
||||
viewport_width &&
|
||||
viewport_height &&
|
||||
viewport_width.positive? &&
|
||||
viewport_height.positive?
|
||||
end
|
||||
end
|
||||
32
app/services/maps/hexagon_polygon_generator.rb
Normal file
32
app/services/maps/hexagon_polygon_generator.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class HexagonPolygonGenerator
|
||||
def initialize(h3_index:)
|
||||
@h3_index = h3_index
|
||||
end
|
||||
|
||||
def call
|
||||
# Parse H3 index from hex string if needed
|
||||
index = h3_index.is_a?(String) ? h3_index.to_i(16) : h3_index
|
||||
|
||||
# Get the boundary coordinates for this H3 hexagon
|
||||
boundary_coordinates = H3.to_boundary(index)
|
||||
|
||||
# Convert to GeoJSON polygon format (lng, lat)
|
||||
polygon_coordinates = boundary_coordinates.map { [_2, _1] }
|
||||
|
||||
# Close the polygon by adding the first point at the end
|
||||
polygon_coordinates << polygon_coordinates.first
|
||||
|
||||
{
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [polygon_coordinates]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :h3_index
|
||||
end
|
||||
end
|
||||
62
app/services/maps/hexagon_request_handler.rb
Normal file
62
app/services/maps/hexagon_request_handler.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class HexagonRequestHandler
|
||||
def initialize(params:, user: nil, stat: nil, start_date: nil, end_date: nil)
|
||||
@params = params
|
||||
@user = user
|
||||
@stat = stat
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
# For authenticated users, we need to find the matching stat
|
||||
stat ||= find_matching_stat
|
||||
|
||||
if stat
|
||||
cached_result = Maps::HexagonCenterManager.new(stat:, user:).call
|
||||
|
||||
return cached_result[:data] if cached_result&.dig(:success)
|
||||
end
|
||||
|
||||
# No pre-calculated data available - return empty feature collection
|
||||
Rails.logger.debug 'No pre-calculated hexagon centers available'
|
||||
empty_feature_collection
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :params, :user, :stat, :start_date, :end_date
|
||||
|
||||
def find_matching_stat
|
||||
return unless user && start_date
|
||||
|
||||
# Parse the date to extract year and month
|
||||
if start_date.is_a?(String)
|
||||
date = Date.parse(start_date)
|
||||
elsif start_date.is_a?(Time)
|
||||
date = start_date.to_date
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
# Find the stat for this user, year, and month
|
||||
user.stats.find_by(year: date.year, month: date.month)
|
||||
rescue Date::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def empty_feature_collection
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => [],
|
||||
'metadata' => {
|
||||
'hexagon_count' => 0,
|
||||
'total_points' => 0,
|
||||
'source' => 'pre_calculated'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,6 +17,8 @@ class OwnTracks::Importer
|
|||
parsed_data = OwnTracks::RecParser.new(file_content).call
|
||||
|
||||
points_data = parsed_data.map do |point|
|
||||
next unless point_valid?(point)
|
||||
|
||||
OwnTracks::Params.new(point).call.merge(
|
||||
import_id: import.id,
|
||||
user_id: user_id,
|
||||
|
|
@ -31,7 +33,7 @@ class OwnTracks::Importer
|
|||
private
|
||||
|
||||
def bulk_insert_points(batch)
|
||||
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
unique_batch = batch.compact.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Point.upsert_all(
|
||||
|
|
@ -42,6 +44,8 @@ class OwnTracks::Importer
|
|||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to bulk insert OwnTracks points for user #{user_id}: #{e.message}")
|
||||
|
||||
create_notification("Failed to process OwnTracks data: #{e.message}")
|
||||
end
|
||||
|
||||
|
|
@ -53,4 +57,10 @@ class OwnTracks::Importer
|
|||
kind: :error
|
||||
)
|
||||
end
|
||||
|
||||
def point_valid?(point)
|
||||
point['lat'].present? &&
|
||||
point['lon'].present? &&
|
||||
point['tst'].present?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ class Stats::CalculateMonth
|
|||
stat.assign_attributes(
|
||||
daily_distance: distance_by_day,
|
||||
distance: distance(distance_by_day),
|
||||
toponyms: toponyms
|
||||
toponyms: toponyms,
|
||||
h3_hex_ids: calculate_h3_hex_ids
|
||||
)
|
||||
stat.save
|
||||
end
|
||||
|
|
@ -82,4 +83,8 @@ class Stats::CalculateMonth
|
|||
def destroy_month_stats(year, month)
|
||||
Stat.where(year:, month:, user:).destroy_all
|
||||
end
|
||||
|
||||
def calculate_h3_hex_ids
|
||||
Stats::HexagonCalculator.new(user.id, year, month).call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
120
app/services/stats/hexagon_calculator.rb
Normal file
120
app/services/stats/hexagon_calculator.rb
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stats::HexagonCalculator
|
||||
# H3 Configuration
|
||||
DEFAULT_H3_RESOLUTION = 8 # Small hexagons for good detail
|
||||
MAX_HEXAGONS = 10_000 # Maximum number of hexagons to prevent memory issues
|
||||
|
||||
class PostGISError < StandardError; end
|
||||
|
||||
def initialize(user_id, year, month)
|
||||
@user = User.find(user_id)
|
||||
@year = year.to_i
|
||||
@month = month.to_i
|
||||
end
|
||||
|
||||
def call(h3_resolution: DEFAULT_H3_RESOLUTION)
|
||||
calculate_h3_hexagon_centers(h3_resolution)
|
||||
end
|
||||
|
||||
def calculate_h3_hex_ids
|
||||
result = calculate_hexagons(DEFAULT_H3_RESOLUTION)
|
||||
return {} if result.nil?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :year, :month
|
||||
|
||||
def calculate_h3_hexagon_centers(h3_resolution)
|
||||
result = calculate_hexagons(h3_resolution)
|
||||
return [] if result.nil?
|
||||
|
||||
# Convert to array format: [h3_index_string, point_count, earliest_timestamp, latest_timestamp]
|
||||
result.map do |h3_index_string, data|
|
||||
[
|
||||
h3_index_string,
|
||||
data[0], # count
|
||||
data[1], # earliest
|
||||
data[2] # latest
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
# Unified hexagon calculation method
|
||||
def calculate_hexagons(h3_resolution)
|
||||
return nil if points.empty?
|
||||
|
||||
begin
|
||||
h3_hash = calculate_h3_indexes(points, h3_resolution)
|
||||
|
||||
if h3_hash.empty?
|
||||
Rails.logger.info "No H3 hex IDs calculated for user #{user.id}, #{year}-#{month} (no data)"
|
||||
return nil
|
||||
end
|
||||
|
||||
if h3_hash.size > MAX_HEXAGONS
|
||||
Rails.logger.warn "Too many hexagons (#{h3_hash.size}), using lower resolution"
|
||||
# Try with lower resolution (larger hexagons)
|
||||
lower_resolution = [h3_resolution - 2, 0].max
|
||||
Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
|
||||
# Create a new instance with lower resolution for recursion
|
||||
return self.class.new(user.id, year, month).calculate_hexagons(lower_resolution)
|
||||
end
|
||||
|
||||
Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}"
|
||||
h3_hash
|
||||
rescue StandardError => e
|
||||
message = "Failed to calculate H3 hexagon centers: #{e.message}"
|
||||
ExceptionReporter.call(e, message) if defined?(ExceptionReporter)
|
||||
raise PostGISError, message
|
||||
end
|
||||
end
|
||||
|
||||
def start_timestamp
|
||||
DateTime.new(year, month, 1).to_i
|
||||
end
|
||||
|
||||
def end_timestamp
|
||||
DateTime.new(year, month, -1).to_i # -1 returns last day of month
|
||||
end
|
||||
|
||||
def points
|
||||
return @points if defined?(@points)
|
||||
|
||||
@points = user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.where.not(lonlat: nil)
|
||||
.select(:lonlat, :timestamp)
|
||||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def calculate_h3_indexes(points, h3_resolution)
|
||||
h3_data = {}
|
||||
|
||||
points.find_each do |point|
|
||||
# Extract lat/lng from PostGIS point
|
||||
coordinates = [point.lonlat.y, point.lonlat.x] # [lat, lng] for H3
|
||||
|
||||
# Get H3 index for this point
|
||||
h3_index = H3.from_geo_coordinates(coordinates, h3_resolution.clamp(0, 15))
|
||||
h3_index_string = h3_index.to_s(16) # Convert to hex string immediately
|
||||
|
||||
# Initialize or update data for this hexagon
|
||||
if h3_data[h3_index_string]
|
||||
data = h3_data[h3_index_string]
|
||||
data[0] += 1 # increment count
|
||||
data[1] = [data[1], point.timestamp].min # update earliest
|
||||
data[2] = [data[2], point.timestamp].max # update latest
|
||||
else
|
||||
h3_data[h3_index_string] = [1, point.timestamp, point.timestamp] # [count, earliest, latest]
|
||||
end
|
||||
end
|
||||
|
||||
h3_data
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="<%= asset_path 'favicon/apple-touch-icon.png' %>">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="<%= asset_path 'favicon/favicon-32x32.png' %>">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="<%= asset_path 'favicon/favicon-16x16.png' %>">
|
||||
<link rel="manifest" href="<%= asset_path 'favicon/site.webmanifest' %>">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="<%= asset_path 'favicon/safari-pinned-tab.svg' %>" color="#5bbad5">
|
||||
<link rel="shortcut icon" href="<%= asset_path 'favicon/favicon.ico' %>">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
|
|
|
|||
|
|
@ -82,16 +82,35 @@
|
|||
<h3 class="font-bold text-lg mb-4">Import your data</h3>
|
||||
<p class="mb-4 text-sm text-gray-600">Upload a ZIP file containing your exported Dawarich data to restore your points, trips, and settings.</p>
|
||||
|
||||
<%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: { turbo: false } do |f| %>
|
||||
<%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: {
|
||||
turbo: false,
|
||||
controller: "user-data-archive-direct-upload",
|
||||
user_data_archive_direct_upload_url_value: rails_direct_uploads_url,
|
||||
user_data_archive_direct_upload_user_trial_value: current_user.trial?,
|
||||
user_data_archive_direct_upload_target: "form"
|
||||
} do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :archive, class: 'label' do %>
|
||||
<span class="label-text">Select ZIP archive</span>
|
||||
<% end %>
|
||||
<%= f.file_field :archive, accept: '.zip', required: true, class: 'file-input file-input-bordered w-full' %>
|
||||
<%= f.file_field :archive,
|
||||
accept: '.zip',
|
||||
required: true,
|
||||
direct_upload: true,
|
||||
class: 'file-input file-input-bordered w-full',
|
||||
data: { user_data_archive_direct_upload_target: "input" } %>
|
||||
<div class="text-sm text-gray-500 mt-2">
|
||||
File will be uploaded directly to storage. Please be patient during upload.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<%= f.submit "Import Data", class: 'btn btn-primary', data: { disable_with: 'Importing...' } %>
|
||||
<%= f.submit "Import Data",
|
||||
class: 'btn btn-primary',
|
||||
data: {
|
||||
disable_with: 'Importing...',
|
||||
user_data_archive_direct_upload_target: "submit"
|
||||
} %>
|
||||
<button type="button" class="btn" onclick="import_modal.close()">Cancel</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<p class="py-6">and take control over your location data.</p>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body ') do |f| %>
|
||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :email, class: 'label' do %>
|
||||
<span class="label-text">Email</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body ') do |f| %>
|
||||
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :email, class: 'label' do %>
|
||||
<span class="label-text">Email</span>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@
|
|||
<div class="text-xs text-gray-500 mt-2">
|
||||
File format is automatically detected during upload.
|
||||
</div>
|
||||
<% if current_user.trial? %>
|
||||
<div class="text-xs text-orange-600 mt-2 font-medium">
|
||||
Trial limitations: Max 5 imports, 10MB per file.
|
||||
<br>
|
||||
Current imports: <%= current_user.imports.count %>/5
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -18,6 +25,7 @@
|
|||
controller: "direct-upload",
|
||||
direct_upload_url_value: rails_direct_uploads_url,
|
||||
direct_upload_user_trial_value: current_user.trial?,
|
||||
direct_upload_current_imports_count_value: current_user.imports.count,
|
||||
direct_upload_target: "form"
|
||||
} do |form| %>
|
||||
<label class="form-control w-full max-w-xs my-5">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<% if ENV['POSTHOG_ENABLED'] == 'true' %>
|
||||
<%= javascript_include_tag "posthog", "data-turbo-track": "reload" %>
|
||||
<% end %>
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= javascript_include_tag "https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js" %>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,94 @@
|
|||
<% if user_signed_in? %>
|
||||
<div data-controller="onboarding-modal"
|
||||
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
|
||||
data-onboarding-modal-showable-value="true">
|
||||
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Start tracking your location!</h3>
|
||||
<p class="py-4">
|
||||
To start tracking your location and putting it on the map, you need to configure your mobile application.
|
||||
<div class="modal-box max-w-2xl bg-base-200">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="text-2xl font-bold text-primary mb-2 flex items-center justify-center gap-2">
|
||||
<%= icon 'goal' %> Start Tracking Your Location!</h3>
|
||||
<p class="text-base-content/70">
|
||||
Welcome to Dawarich! Let's get you set up to start tracking and visualizing your location data.
|
||||
</p>
|
||||
<p>
|
||||
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-6">
|
||||
<!-- Step 1: Download App -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="badge badge-primary badge-lg">1</div>
|
||||
<h4 class="text-lg font-semibold">Download the Official App</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/80 mb-4">
|
||||
Get the official Dawarich app from the App Store to start tracking your location.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<div class="flex justify-center">
|
||||
<%= link_to 'https://apps.apple.com/de/app/dawarich/id6739544999?itscg=30200&itsct=apps_box_badge&mttnsubad=6739544999',
|
||||
class: 'inline-block rounded-lg border-2 border-transparent hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-300 ease-in-out transform hover:scale-105' do %>
|
||||
<%= image_tag 'Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg',
|
||||
class: 'h-12 transition-opacity duration-300' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="badge badge-primary badge-lg">2</div>
|
||||
<h4 class="text-lg font-semibold">Scan QR Code to Connect</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/80 mb-4">
|
||||
Scan this QR code with the Dawarich app to automatically configure your connection.
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white p-3 rounded-lg shadow-inner">
|
||||
<%= api_key_qr_code(current_user, size: 3) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="badge badge-secondary badge-lg">3</div>
|
||||
<h4 class="text-lg font-semibold">Manual Setup (Alternative)</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/80">
|
||||
Alternatively, you can manually grab your API key from
|
||||
<%= link_to 'Settings', settings_path, class: 'link link-primary font-medium' %>
|
||||
and follow the setup instructions in our
|
||||
<%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding',
|
||||
class: 'link link-primary font-medium', target: '_blank', rel: 'noopener' %>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action mt-8">
|
||||
<div class="text-center flex-1">
|
||||
<p class="text-xs text-base-content/60 mb-4">
|
||||
Need help? Check out our
|
||||
<%= link_to 'documentation', 'https://dawarich.app/docs/category/tutorials?utm_source=app&utm_medium=referral&utm_campaign=onboarding',
|
||||
class: 'link link-primary text-xs', target: '_blank', rel: 'noopener' %>
|
||||
for more guidance.
|
||||
</p>
|
||||
</div>
|
||||
<form method="dialog">
|
||||
<button class="btn">Close</button>
|
||||
<button class="btn btn-primary btn-wide">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Got it, let's start!
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal backdrop -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
|
|
|
|||
|
|
@ -76,7 +76,11 @@
|
|||
<div class="join">
|
||||
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %>
|
||||
<span class="join-item btn btn-sm <%= trial_button_class(current_user) %>">
|
||||
<% if current_user.active_until.past? %>
|
||||
<span class="tooltip tooltip-bottom">Trial expired 🥺</span>
|
||||
<% else %>
|
||||
<span class="tooltip tooltip-bottom" data-tip="Your trial will end in <%= distance_of_time_in_words(current_user.active_until, Time.current) %>"><%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining</span>
|
||||
<% end %>
|
||||
</span><span class="join-item btn btn-sm btn-success">
|
||||
Subscribe
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@
|
|||
<%= options_for_select([
|
||||
['1 hour', '1h'],
|
||||
['12 hours', '12h'],
|
||||
['24 hours', '24h'],
|
||||
['Permanent', 'permanent']
|
||||
['24 hours', '24h']
|
||||
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,3 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shared Stats - <%= Date::MONTHNAMES[@month] %> <%= @year %></title>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
|
||||
<% if @self_hosted %>
|
||||
<!-- ProtomapsL for vector tiles -->
|
||||
<script src="https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js"></script>
|
||||
<% end %>
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<div class="min-h-screen bg-base-100 mx-auto">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Monthly Digest Header -->
|
||||
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background-image: url('<%= month_bg_image(@stat) %>');">
|
||||
|
|
@ -72,14 +43,26 @@
|
|||
<!-- Map Summary - Hexagon View -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body p-0">
|
||||
<!-- Map Controls -->
|
||||
<div class="p-4 border-b border-base-300 bg-base-50">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-semibold text-lg flex items-center gap-2">
|
||||
<%= icon 'map' %> Location Hexagons
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hexagon Map Container -->
|
||||
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
|
||||
<div class="w-full h-96 border border-base-300 relative overflow-hidden">
|
||||
<div id="public-monthly-stats-map" class="w-full h-full"
|
||||
data-controller="public-stat-map"
|
||||
data-public-stat-map-year-value="<%= @year %>"
|
||||
data-public-stat-map-month-value="<%= @month %>"
|
||||
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
|
||||
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
|
||||
data-public-stat-map-hexagons-available-value="<%= @hexagons_available.to_s %>"
|
||||
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
|
|
@ -178,8 +161,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map is now handled by the Stimulus controller -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
development:
|
||||
default: &default
|
||||
adapter: redis
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
channel_prefix: dawarich_development
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
channel_prefix: dawarich_production
|
||||
|
||||
staging:
|
||||
<<: *default
|
||||
channel_prefix: dawarich_staging
|
||||
|
||||
test:
|
||||
adapter: test
|
||||
|
||||
production:
|
||||
adapter: redis
|
||||
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
|
||||
channel_prefix: dawarich_production
|
||||
|
|
|
|||
|
|
@ -23,4 +23,4 @@ production:
|
|||
|
||||
staging:
|
||||
<<: *default
|
||||
database: <%= ENV['DATABASE_NAME'] || 'dawarich_staging' %>
|
||||
database: <%= ENV['DATABASE_NAME'] || 'dawarich_production' %>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Rails.application.configure do
|
|||
# config.assets.css_compressor = :sass
|
||||
|
||||
# Do not fallback to assets pipeline if a precompiled asset is missed.
|
||||
config.assets.compile = true
|
||||
config.assets.compile = false
|
||||
|
||||
config.assets.content_type = {
|
||||
geojson: 'application/geo+json'
|
||||
|
|
|
|||
121
config/environments/staging.rb
Normal file
121
config/environments/staging.rb
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'active_support/core_ext/integer/time'
|
||||
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
|
||||
# Code is not reloaded between requests.
|
||||
config.enable_reloading = false
|
||||
|
||||
# Eager load code on boot. This eager loads most of Rails and
|
||||
# your application in memory, allowing both threaded web servers
|
||||
# and those relying on copy on write to perform better.
|
||||
# Rake tasks automatically ignore this option for performance.
|
||||
config.eager_load = true
|
||||
|
||||
# Full error reports are disabled and caching is turned on.
|
||||
config.consider_all_requests_local = false
|
||||
config.action_controller.perform_caching = true
|
||||
|
||||
# Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment
|
||||
# key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files).
|
||||
# config.require_master_key = true
|
||||
|
||||
# Enable static file serving from the `/public` folder (turn off if using NGINX/Apache for it).
|
||||
config.public_file_server.enabled = true
|
||||
|
||||
# Compress CSS using a preprocessor.
|
||||
# config.assets.css_compressor = :sass
|
||||
|
||||
# Do not fallback to assets pipeline if a precompiled asset is missed.
|
||||
config.assets.compile = false
|
||||
|
||||
config.assets.content_type = {
|
||||
geojson: 'application/geo+json'
|
||||
}
|
||||
|
||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||
# config.asset_host = "http://assets.example.com"
|
||||
|
||||
# Specifies the header that your server uses for sending files.
|
||||
# config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
|
||||
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
|
||||
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3
|
||||
|
||||
config.silence_healthcheck_path = '/api/v1/health'
|
||||
|
||||
# Mount Action Cable outside main process or domain.
|
||||
# config.action_cable.mount_path = nil
|
||||
# config.action_cable.url = "wss://example.com/cable"
|
||||
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
|
||||
|
||||
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
||||
# Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
|
||||
# config.assume_ssl = true
|
||||
|
||||
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
|
||||
config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https'
|
||||
|
||||
# Direct logs to STDOUT
|
||||
config.logger = ActiveSupport::Logger.new($stdout)
|
||||
config.lograge.enabled = true
|
||||
config.lograge.formatter = Lograge::Formatters::Json.new
|
||||
|
||||
# Prepend all log lines with the following tags.
|
||||
config.log_tags = [:request_id]
|
||||
|
||||
# Info include generic and useful information about system operation, but avoids logging too much
|
||||
# information to avoid inadvertent exposure of personally identifiable information (PII). If you
|
||||
# want to log everything, set the level to "debug".
|
||||
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
|
||||
|
||||
# Use a different cache store in production.
|
||||
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
|
||||
|
||||
# Use a real queuing backend for Active Job (and separate queues per environment).
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
|
||||
config.action_mailer.perform_caching = false
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
|
||||
# the I18n.default_locale when a translation cannot be found).
|
||||
config.i18n.fallbacks = true
|
||||
|
||||
# Don't log any deprecations.
|
||||
config.active_support.report_deprecations = false
|
||||
|
||||
# Do not dump schema after migrations.
|
||||
config.active_record.dump_schema_after_migration = false
|
||||
|
||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||
# config.hosts = [
|
||||
# "example.com", # Allow requests from example.com
|
||||
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
|
||||
# ]
|
||||
# Skip DNS rebinding protection for the health check endpoint.
|
||||
config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } }
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: ENV['SMTP_SERVER'],
|
||||
port: ENV['SMTP_PORT'],
|
||||
domain: ENV['SMTP_DOMAIN'],
|
||||
user_name: ENV['SMTP_USERNAME'],
|
||||
password: ENV['SMTP_PASSWORD'],
|
||||
authentication: 'plain',
|
||||
enable_starttls: true,
|
||||
open_timeout: 5,
|
||||
read_timeout: 5
|
||||
}
|
||||
end
|
||||
|
|
@ -4,7 +4,6 @@ class DawarichSettings
|
|||
BASIC_PAID_PLAN_LIMIT = 10_000_000 # 10 million points
|
||||
|
||||
class << self
|
||||
|
||||
def reverse_geocoding_enabled?
|
||||
@reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Mime::Type.register 'application/geo+json', :geojson
|
||||
Mime::Type.register 'application/manifest+json', :webmanifest
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled?
|
||||
if defined?(Rails::Server) && !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled?
|
||||
require 'prometheus_exporter/middleware'
|
||||
require 'prometheus_exporter/instrumentation'
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,6 @@
|
|||
# to asset_path in the _favicon.html.erb partial.
|
||||
|
||||
Rails.application.config.assets.configure do |env|
|
||||
mime_type = 'application/manifest+json'
|
||||
extensions = ['.webmanifest']
|
||||
|
||||
if Sprockets::VERSION.to_i >= 4
|
||||
extensions << '.webmanifest.erb'
|
||||
env.register_preprocessor(mime_type, Sprockets::ERBProcessor)
|
||||
end
|
||||
|
||||
env.register_mime_type(mime_type, extensions: extensions)
|
||||
# Register .webmanifest files with the correct MIME type
|
||||
env.register_mime_type 'application/manifest+json', extensions: ['.webmanifest']
|
||||
end
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ Rails.application.routes.draw do
|
|||
|
||||
root to: 'home#index'
|
||||
|
||||
# iOS mobile auth success endpoint
|
||||
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
|
||||
|
||||
if SELF_HOSTED
|
||||
devise_for :users, skip: [:registrations]
|
||||
as :user do
|
||||
|
|
|
|||
|
|
@ -39,3 +39,8 @@ daily_track_generation_job:
|
|||
cron: "0 */4 * * *" # every 4 hours
|
||||
class: "Tracks::DailyGenerationJob"
|
||||
queue: tracks
|
||||
|
||||
nightly_reverse_geocoding_job:
|
||||
cron: "15 1 * * *" # every day at 01:15
|
||||
class: "Points::NightlyReverseGeocodingJob"
|
||||
queue: tracks
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSharingFieldsToStats < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :stats, :sharing_settings, :jsonb, default: {}
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_column :stats, :sharing_settings, :jsonb
|
||||
add_column :stats, :sharing_uuid, :uuid
|
||||
|
||||
change_column_default :stats, :sharing_settings, {}
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :stats, :sharing_settings
|
||||
remove_column :stats, :sharing_uuid
|
||||
end
|
||||
end
|
||||
|
|
|
|||
14
db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb
Normal file
14
db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddH3HexIdsToStats < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true
|
||||
safety_assured do
|
||||
add_index :stats, :h3_hex_ids, using: :gin,
|
||||
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
|
||||
algorithm: :concurrently, if_not_exists: true
|
||||
end
|
||||
end
|
||||
end
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -222,7 +222,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
|
|||
t.jsonb "daily_distance", default: {}
|
||||
t.jsonb "sharing_settings", default: {}
|
||||
t.uuid "sharing_uuid"
|
||||
t.jsonb "h3_hex_ids", default: {}
|
||||
t.index ["distance"], name: "index_stats_on_distance"
|
||||
t.index ["h3_hex_ids"], name: "index_stats_on_h3_hex_ids", where: "((h3_hex_ids IS NOT NULL) AND (h3_hex_ids <> '{}'::jsonb))", using: :gin
|
||||
t.index ["month"], name: "index_stats_on_month"
|
||||
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
|
||||
t.index ["user_id"], name: "index_stats_on_user_id"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM ruby:3.4.1-slim
|
||||
FROM ruby:3.4.6-slim
|
||||
|
||||
ENV APP_PATH=/var/app
|
||||
ENV BUNDLE_VERSION=2.5.21
|
||||
|
|
@ -13,6 +13,7 @@ ENV SIDEKIQ_PASSWORD=password
|
|||
ENV PGSSENCMODE=disable
|
||||
|
||||
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
wget \
|
||||
build-essential \
|
||||
git \
|
||||
|
|
@ -24,10 +25,12 @@ RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no
|
|||
libgeos-dev libgeos++-dev \
|
||||
imagemagick \
|
||||
tzdata \
|
||||
nodejs \
|
||||
yarn \
|
||||
less \
|
||||
libjemalloc2 libjemalloc-dev \
|
||||
cmake \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g yarn \
|
||||
&& mkdir -p $APP_PATH \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
@ -42,7 +45,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
|
|||
ENV RUBY_YJIT_ENABLE=1
|
||||
|
||||
# Update RubyGems and install Bundler
|
||||
RUN gem update --system 3.6.2 \
|
||||
RUN gem update --system 3.6.9 \
|
||||
&& gem install bundler --version "$BUNDLE_VERSION" \
|
||||
&& rm -rf $GEM_HOME/cache/*
|
||||
|
||||
|
|
@ -52,7 +55,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
|
|||
|
||||
RUN bundle config set --local path 'vendor/bundle' \
|
||||
&& bundle install --jobs 4 --retry 3 \
|
||||
&& rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem
|
||||
&& rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem
|
||||
|
||||
COPY ../. ./
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM ruby:3.4.1-slim
|
||||
FROM ruby:3.4.6-slim
|
||||
|
||||
ENV APP_PATH=/var/app
|
||||
ENV BUNDLE_VERSION=2.5.21
|
||||
|
|
@ -8,6 +8,7 @@ ENV RAILS_PORT=3000
|
|||
ENV RAILS_ENV=production
|
||||
|
||||
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
wget \
|
||||
build-essential \
|
||||
git \
|
||||
|
|
@ -19,10 +20,12 @@ RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no
|
|||
libgeos-dev libgeos++-dev \
|
||||
imagemagick \
|
||||
tzdata \
|
||||
nodejs \
|
||||
yarn \
|
||||
less \
|
||||
libjemalloc2 libjemalloc-dev \
|
||||
cmake \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g yarn \
|
||||
&& mkdir -p $APP_PATH \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
@ -37,7 +40,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
|
|||
ENV RUBY_YJIT_ENABLE=1
|
||||
|
||||
# Update gem system and install bundler
|
||||
RUN gem update --system 3.6.2 \
|
||||
RUN gem update --system 3.6.9 \
|
||||
&& gem install bundler --version "$BUNDLE_VERSION" \
|
||||
&& rm -rf $GEM_HOME/cache/*
|
||||
|
||||
|
|
@ -49,7 +52,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
|
|||
RUN bundle config set --local path 'vendor/bundle' \
|
||||
&& bundle config set --local without 'development test' \
|
||||
&& bundle install --jobs 4 --retry 3 \
|
||||
&& rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem
|
||||
&& rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem
|
||||
|
||||
COPY ../. ./
|
||||
|
||||
|
|
|
|||
43
lib/tasks/webmanifest.rake
Normal file
43
lib/tasks/webmanifest.rake
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
namespace :webmanifest do
|
||||
desc "Generate site.webmanifest in public directory with correct asset paths"
|
||||
task :generate => :environment do
|
||||
require 'erb'
|
||||
|
||||
# Make sure assets are compiled first by loading the manifest
|
||||
Rails.application.assets_manifest.assets
|
||||
|
||||
# Get the correct asset paths
|
||||
icon_192_path = ActionController::Base.helpers.asset_path('favicon/android-chrome-192x192.png')
|
||||
icon_512_path = ActionController::Base.helpers.asset_path('favicon/android-chrome-512x512.png')
|
||||
|
||||
# Generate the manifest content
|
||||
manifest_content = {
|
||||
"name": "Dawarich",
|
||||
"short_name": "Dawarich",
|
||||
"icons": [
|
||||
{
|
||||
"src": icon_192_path,
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": icon_512_path,
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}.to_json
|
||||
|
||||
# Write to public/site.webmanifest
|
||||
File.write(Rails.root.join('public/site.webmanifest'), manifest_content)
|
||||
puts "Generated public/site.webmanifest with correct asset paths"
|
||||
end
|
||||
end
|
||||
|
||||
# Hook to automatically generate webmanifest after assets:precompile
|
||||
Rake::Task['assets:precompile'].enhance do
|
||||
Rake::Task['webmanifest:generate'].invoke
|
||||
end
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"2A275P77DQ.app.dawarich.Dawarich"
|
||||
"2A275P77DQ.app.dawarich.Dawarich",
|
||||
"3DJN84WAS8.app.dawarich.Dawarich"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
public/site.webmanifest
Normal file
19
public/site.webmanifest
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Dawarich",
|
||||
"short_name": "Dawarich",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/favicon/android-chrome-192x192-f9610e2af28e4e48ff0472572c0cb9e3902d29bccc2b07f8f03aabf684822355.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/assets/favicon/android-chrome-512x512-c2ec8132d773ae99f53955360cdd5691bb38e0ed141bddebd39d896b78b5afb6.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ FactoryBot.define do
|
|||
|
||||
trait :with_sharing_enabled do
|
||||
after(:create) do |stat, _evaluator|
|
||||
stat.enable_sharing!(expiration: 'permanent')
|
||||
stat.enable_sharing!(expiration: '24h')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
FactoryBot.define do
|
||||
factory :user do
|
||||
sequence :email do |n|
|
||||
"user#{n}@example.com"
|
||||
"user#{n}-#{Time.current.to_f}@example.com"
|
||||
end
|
||||
|
||||
status { :active }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
|
|||
let(:area) { create(:area, user: user) }
|
||||
|
||||
it 'calls the AreaVisitsCalculationService' do
|
||||
allow(User).to receive(:find_each).and_yield(user)
|
||||
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
|
||||
|
||||
described_class.new.perform
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
|
|||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
|
@ -69,8 +67,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
|
|||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
|||
]
|
||||
allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(chunks)
|
||||
|
||||
active_users_mock = double('ActiveRecord::Relation')
|
||||
allow(User).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:find_each).and_yield(user_with_points)
|
||||
|
||||
chunks.each do |chunk|
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
|
|
@ -94,6 +100,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
|||
.and_return(time_chunks_instance)
|
||||
allow(time_chunks_instance).to receive(:call).and_return(custom_chunks)
|
||||
|
||||
active_users_mock = double('ActiveRecord::Relation')
|
||||
allow(User).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:find_each).and_yield(user_with_points)
|
||||
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
start_at: custom_chunks.first.first,
|
||||
|
|
|
|||
125
spec/jobs/points/nightly_reverse_geocoding_job_spec.rb
Normal file
125
spec/jobs/points/nightly_reverse_geocoding_job_spec.rb
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
# Clear any existing jobs and points to ensure test isolation
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
Point.delete_all
|
||||
end
|
||||
|
||||
context 'when reverse geocoding is disabled' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
let!(:point_without_geocoding) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
|
||||
it 'does not process any points' do
|
||||
expect_any_instance_of(Point).not_to receive(:async_reverse_geocode)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'returns early without querying points' do
|
||||
allow(Point).to receive(:not_reverse_geocoded)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(Point).not_to have_received(:not_reverse_geocoded)
|
||||
end
|
||||
|
||||
it 'does not enqueue any ReverseGeocodingJob jobs' do
|
||||
expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reverse geocoding is enabled' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
context 'with no points needing reverse geocoding' do
|
||||
let!(:geocoded_point) do
|
||||
create(:point, user: user, reverse_geocoded_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'does not process any points' do
|
||||
expect_any_instance_of(Point).not_to receive(:async_reverse_geocode)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'does not enqueue any ReverseGeocodingJob jobs' do
|
||||
expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points needing reverse geocoding' do
|
||||
let!(:point_without_geocoding1) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
let!(:point_without_geocoding2) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
let!(:geocoded_point) do
|
||||
create(:point, user: user, reverse_geocoded_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'processes all points that need reverse geocoding' do
|
||||
expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times
|
||||
end
|
||||
|
||||
it 'enqueues jobs with correct parameters' do
|
||||
expect { described_class.perform_now }
|
||||
.to have_enqueued_job(ReverseGeocodingJob)
|
||||
.with('Point', point_without_geocoding1.id)
|
||||
.and have_enqueued_job(ReverseGeocodingJob)
|
||||
.with('Point', point_without_geocoding2.id)
|
||||
end
|
||||
|
||||
it 'uses find_each with correct batch size' do
|
||||
relation_mock = double('ActiveRecord::Relation')
|
||||
allow(Point).to receive(:not_reverse_geocoded).and_return(relation_mock)
|
||||
allow(relation_mock).to receive(:find_each).with(batch_size: 1000)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(relation_mock).to have_received(:find_each).with(batch_size: 1000)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'queue configuration' do
|
||||
it 'uses the reverse_geocoding queue' do
|
||||
expect(described_class.queue_name).to eq('reverse_geocoding')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error handling' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
let!(:point_without_geocoding) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
|
||||
context 'when a point fails to reverse geocode' do
|
||||
before do
|
||||
allow_any_instance_of(Point).to receive(:async_reverse_geocode).and_raise(StandardError, 'API error')
|
||||
end
|
||||
|
||||
it 'continues processing other points despite individual failures' do
|
||||
expect { described_class.perform_now }.to raise_error(StandardError, 'API error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -26,6 +26,11 @@ RSpec.describe Tracks::DailyGenerationJob, type: :job do
|
|||
active_user.update!(points_count: active_user.points.count)
|
||||
trial_user.update!(points_count: trial_user.points.count)
|
||||
|
||||
# Mock User.active_or_trial to only return test users
|
||||
active_or_trial_mock = double('ActiveRecord::Relation')
|
||||
allow(User).to receive(:active_or_trial).and_return(active_or_trial_mock)
|
||||
allow(active_or_trial_mock).to receive(:find_each).and_yield(active_user).and_yield(trial_user)
|
||||
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UsersMailer, type: :mailer do
|
||||
let(:user) { create(:user, email: 'test@example.com') }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app'))
|
||||
|
|
@ -14,11 +14,11 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('Welcome to Dawarich!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
|
||||
it 'renders the body' do
|
||||
expect(mail.body.encoded).to match('test@example.com')
|
||||
expect(mail.body.encoded).to match(user.email)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('Explore Dawarich features!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('💔 Your Dawarich trial expired')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -66,6 +66,42 @@ RSpec.describe Import, type: :model do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'import count validation' do
|
||||
context 'when user is a trial user' do
|
||||
let(:user) do
|
||||
user = create(:user)
|
||||
user.update!(status: :trial)
|
||||
user
|
||||
end
|
||||
|
||||
it 'allows imports when under the limit' do
|
||||
3.times { |i| create(:import, user: user, name: "import_#{i}") }
|
||||
new_import = build(:import, user: user, name: 'new_import')
|
||||
|
||||
expect(new_import).to be_valid
|
||||
end
|
||||
|
||||
it 'prevents creating more than 5 imports' do
|
||||
5.times { |i| create(:import, user: user, name: "import_#{i}") }
|
||||
new_import = build(:import, user: user, name: 'import_6')
|
||||
|
||||
expect(new_import).not_to be_valid
|
||||
expect(new_import.errors[:base]).to include('Trial users can only create up to 5 imports. Please subscribe to import more files.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is an active user' do
|
||||
let(:user) { create(:user, status: :active) }
|
||||
|
||||
it 'does not validate import count limit' do
|
||||
7.times { |i| create(:import, user: user, name: "import_#{i}") }
|
||||
new_import = build(:import, user: user, name: 'import_8')
|
||||
|
||||
expect(new_import).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
|
|
@ -116,4 +152,28 @@ RSpec.describe Import, type: :model do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#recalculate_stats' do
|
||||
let(:import) { create(:import, user:) }
|
||||
let!(:point1) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 11, 15).to_i) }
|
||||
let!(:point2) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 12, 5).to_i) }
|
||||
|
||||
it 'enqueues stats calculation jobs for each tracked month' do
|
||||
expect do
|
||||
import.send(:recalculate_stats)
|
||||
end.to have_enqueued_job(Stats::CalculatingJob)
|
||||
.with(user.id, 2024, 11)
|
||||
.and have_enqueued_job(Stats::CalculatingJob).with(user.id, 2024, 12)
|
||||
end
|
||||
|
||||
context 'when import has no points' do
|
||||
let(:empty_import) { create(:import, user:) }
|
||||
|
||||
it 'does not enqueue any jobs' do
|
||||
expect do
|
||||
empty_import.send(:recalculate_stats)
|
||||
end.not_to have_enqueued_job(Stats::CalculatingJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -53,11 +53,17 @@ RSpec.describe Point, type: :model do
|
|||
end
|
||||
|
||||
describe '.not_reverse_geocoded' do
|
||||
let(:point) { create(:point, country: 'Country', city: 'City') }
|
||||
let(:point_without_address) { create(:point, city: nil, country: nil) }
|
||||
let!(:point) { create(:point, country: 'Country', city: 'City', reverse_geocoded_at: Time.current) }
|
||||
let!(:point_without_address) { create(:point, city: nil, country: nil, reverse_geocoded_at: nil) }
|
||||
|
||||
it 'returns points without reverse geocoded address' do
|
||||
expect(described_class.not_reverse_geocoded).to eq([point_without_address])
|
||||
# Trigger creation of both points
|
||||
point
|
||||
point_without_address
|
||||
|
||||
result = described_class.not_reverse_geocoded
|
||||
expect(result).to include(point_without_address)
|
||||
expect(result).not_to include(point)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe HexagonQuery, type: :query do
|
||||
let(:user) { create(:user) }
|
||||
let(:min_lon) { -74.1 }
|
||||
let(:min_lat) { 40.6 }
|
||||
let(:max_lon) { -73.9 }
|
||||
let(:max_lat) { 40.8 }
|
||||
let(:hex_size) { 500 }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets required parameters' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size
|
||||
)
|
||||
|
||||
expect(query.min_lon).to eq(min_lon)
|
||||
expect(query.min_lat).to eq(min_lat)
|
||||
expect(query.max_lon).to eq(max_lon)
|
||||
expect(query.max_lat).to eq(max_lat)
|
||||
expect(query.hex_size).to eq(hex_size)
|
||||
end
|
||||
|
||||
it 'sets optional parameters' do
|
||||
start_date = '2024-06-01T00:00:00Z'
|
||||
end_date = '2024-06-30T23:59:59Z'
|
||||
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
|
||||
expect(query.user_id).to eq(user.id)
|
||||
expect(query.start_date).to eq(start_date)
|
||||
expect(query.end_date).to eq(end_date)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:query) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
context 'with no points' do
|
||||
it 'executes without error and returns empty result' do
|
||||
result = query.call
|
||||
expect(result.to_a).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points in bounding box' do
|
||||
before do
|
||||
# Create test points within the bounding box
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.75,
|
||||
longitude: -73.95,
|
||||
timestamp: Time.new(2024, 6, 16, 14, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns hexagon results with expected structure' do
|
||||
result = query.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
first_hex = result_array.first
|
||||
expect(first_hex).to have_key('geojson')
|
||||
expect(first_hex).to have_key('hex_i')
|
||||
expect(first_hex).to have_key('hex_j')
|
||||
expect(first_hex).to have_key('point_count')
|
||||
expect(first_hex).to have_key('earliest_point')
|
||||
expect(first_hex).to have_key('latest_point')
|
||||
expect(first_hex).to have_key('id')
|
||||
|
||||
# Verify geojson can be parsed
|
||||
geojson = JSON.parse(first_hex['geojson'])
|
||||
expect(geojson).to have_key('type')
|
||||
expect(geojson).to have_key('coordinates')
|
||||
end
|
||||
|
||||
it 'filters by user_id correctly' do
|
||||
other_user = create(:user)
|
||||
# Create points for a different user (should be excluded)
|
||||
create(:point,
|
||||
user: other_user,
|
||||
latitude: 40.72,
|
||||
longitude: -73.98,
|
||||
timestamp: Time.new(2024, 6, 17, 16, 0).to_i)
|
||||
|
||||
result = query.call
|
||||
result_array = result.to_a
|
||||
|
||||
# Should only include hexagons with the specified user's points
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(2) # Only the 2 points from our user
|
||||
end
|
||||
end
|
||||
|
||||
context 'with date filtering' do
|
||||
let(:query_with_dates) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-15T00:00:00Z',
|
||||
end_date: '2024-06-16T23:59:59Z'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create points within and outside the date range
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i) # Within range
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.71,
|
||||
longitude: -74.01,
|
||||
timestamp: Time.new(2024, 6, 20, 12, 0).to_i) # Outside range
|
||||
end
|
||||
|
||||
it 'filters points by date range' do
|
||||
result = query_with_dates.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
# Should only include the point within the date range
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without user_id filter' do
|
||||
let(:query_no_user) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
user1 = create(:user)
|
||||
user2 = create(:user)
|
||||
|
||||
create(:point, user: user1, latitude: 40.7, longitude: -74.0, timestamp: Time.current.to_i)
|
||||
create(:point, user: user2, latitude: 40.75, longitude: -73.95, timestamp: Time.current.to_i)
|
||||
end
|
||||
|
||||
it 'includes points from all users' do
|
||||
result = query_no_user.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
# Should include points from both users
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_date_filter (private method behavior)' do
|
||||
context 'when testing date filter behavior through query execution' do
|
||||
it 'works correctly with start_date only' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-15T00:00:00Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'works correctly with end_date only' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'works correctly with both start_date and end_date' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,7 +17,6 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
min_lat: 40.6,
|
||||
max_lon: -73.9,
|
||||
max_lat: 40.8,
|
||||
hex_size: 1000,
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
}
|
||||
|
|
@ -49,32 +48,45 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
expect(json_response['features']).to be_an(Array)
|
||||
end
|
||||
|
||||
it 'requires all bbox parameters' do
|
||||
incomplete_params = valid_params.except(:min_lon)
|
||||
context 'with no data points' do
|
||||
let(:empty_user) { create(:user) }
|
||||
let(:empty_headers) { { 'Authorization' => "Bearer #{empty_user.api_key}" } }
|
||||
|
||||
get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
expect(json_response['error']).to include('min_lon')
|
||||
end
|
||||
|
||||
it 'handles service validation errors' do
|
||||
invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
|
||||
|
||||
get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'uses custom hex_size when provided' do
|
||||
custom_params = valid_params.merge(hex_size: 500)
|
||||
|
||||
get '/api/v1/maps/hexagons', params: custom_params, headers: headers
|
||||
it 'returns empty feature collection' do
|
||||
get '/api/v1/maps/hexagons', params: valid_params, headers: empty_headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['type']).to eq('FeatureCollection')
|
||||
expect(json_response['features']).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with edge case coordinates' do
|
||||
it 'handles coordinates at dateline' do
|
||||
dateline_params = valid_params.merge(
|
||||
min_lon: 179.0, max_lon: -179.0,
|
||||
min_lat: -1.0, max_lat: 1.0
|
||||
)
|
||||
|
||||
get '/api/v1/maps/hexagons', params: dateline_params, headers: headers
|
||||
|
||||
# Should either succeed or return appropriate error, not crash
|
||||
expect([200, 400, 500]).to include(response.status)
|
||||
end
|
||||
|
||||
it 'handles polar coordinates' do
|
||||
polar_params = valid_params.merge(
|
||||
min_lon: -180.0, max_lon: 180.0,
|
||||
min_lat: 85.0, max_lat: 90.0
|
||||
)
|
||||
|
||||
get '/api/v1/maps/hexagons', params: polar_params, headers: headers
|
||||
|
||||
# Should either succeed or return appropriate error, not crash
|
||||
expect([200, 400, 500]).to include(response.status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -157,6 +169,87 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
expect(json_response['error']).to eq('Shared stats not found or no longer available')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with pre-calculated hexagon centers' do
|
||||
let(:pre_calculated_centers) do
|
||||
[
|
||||
['8a1fb46622dffff', 5, 1_717_200_000, 1_717_203_600], # h3_index, count, earliest, latest timestamps
|
||||
['8a1fb46622e7fff', 3, 1_717_210_000, 1_717_213_600],
|
||||
['8a1fb46632dffff', 8, 1_717_220_000, 1_717_223_600]
|
||||
]
|
||||
end
|
||||
let(:stat) do
|
||||
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6, h3_hex_ids: pre_calculated_centers)
|
||||
end
|
||||
|
||||
it 'uses pre-calculated hexagon centers instead of on-the-fly calculation' do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['type']).to eq('FeatureCollection')
|
||||
expect(json_response['features'].length).to eq(3)
|
||||
expect(json_response['metadata']['pre_calculated']).to be true
|
||||
expect(json_response['metadata']['count']).to eq(3)
|
||||
|
||||
# Verify hexagon properties are generated correctly
|
||||
feature = json_response['features'].first
|
||||
expect(feature['type']).to eq('Feature')
|
||||
expect(feature['geometry']['type']).to eq('Polygon')
|
||||
expect(feature['geometry']['coordinates'].first).to be_an(Array)
|
||||
expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing vertex
|
||||
|
||||
# Verify properties include timestamp data
|
||||
expect(feature['properties']['earliest_point']).to be_present
|
||||
expect(feature['properties']['latest_point']).to be_present
|
||||
end
|
||||
|
||||
it 'generates proper hexagon polygons from centers' do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
feature = json_response['features'].first
|
||||
coordinates = feature['geometry']['coordinates'].first
|
||||
|
||||
# Verify hexagon has 6 unique vertices plus closing vertex
|
||||
expect(coordinates.length).to eq(7)
|
||||
expect(coordinates.first).to eq(coordinates.last) # Closed polygon
|
||||
expect(coordinates.uniq.length).to eq(6) # 6 unique vertices
|
||||
|
||||
# Verify all vertices are different (not collapsed to a point)
|
||||
coordinates[0..5].each_with_index do |vertex, i|
|
||||
next_vertex = coordinates[(i + 1) % 6]
|
||||
expect(vertex).not_to eq(next_vertex)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with legacy area_too_large hexagon data' do
|
||||
let(:stat) do
|
||||
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
|
||||
h3_hex_ids: { 'area_too_large' => true })
|
||||
end
|
||||
|
||||
before do
|
||||
# Create points so that the service can potentially succeed
|
||||
5.times do |i|
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.001),
|
||||
longitude: -74.0 + (i * 0.001),
|
||||
timestamp: Time.new(2024, 6, 15, 12, i).to_i)
|
||||
end
|
||||
end
|
||||
|
||||
it 'handles legacy area_too_large flag gracefully' do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
# The endpoint should handle the legacy data gracefully and not crash
|
||||
# We're primarily testing that the condition `@stat&.h3_hex_ids&.dig('area_too_large')` is covered
|
||||
expect([200, 400, 500]).to include(response.status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'without authentication' do
|
||||
|
|
@ -220,6 +313,59 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
expect(json_response['error']).to eq('No data found for the specified date range')
|
||||
expect(json_response['point_count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'requires date range parameters' do
|
||||
get '/api/v1/maps/hexagons/bounds', headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('No date range specified')
|
||||
end
|
||||
|
||||
it 'handles different timestamp formats' do
|
||||
string_date_params = {
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
}
|
||||
|
||||
get '/api/v1/maps/hexagons/bounds', params: string_date_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
|
||||
end
|
||||
|
||||
it 'handles numeric string timestamp format' do
|
||||
numeric_string_params = {
|
||||
start_date: '1717200000', # June 1, 2024 in timestamp
|
||||
end_date: '1719791999' # June 30, 2024 in timestamp
|
||||
}
|
||||
|
||||
get '/api/v1/maps/hexagons/bounds', params: numeric_string_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
|
||||
end
|
||||
|
||||
context 'error handling' do
|
||||
it 'handles invalid date format gracefully' do
|
||||
invalid_date_params = {
|
||||
start_date: 'invalid-date',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
}
|
||||
|
||||
get '/api/v1/maps/hexagons/bounds', params: invalid_date_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Invalid date format')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with public sharing UUID' do
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ RSpec.describe 'Authentication', type: :request do
|
|||
let(:user) { create(:user, password: 'password123') }
|
||||
|
||||
before do
|
||||
stub_request(:get, "https://api.github.com/repos/Freika/dawarich/tags")
|
||||
stub_request(:get, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => /.*/,
|
||||
'Host' => 'api.github.com', 'User-Agent' => /.*/ })
|
||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
|
|
@ -66,4 +66,104 @@ RSpec.describe 'Authentication', type: :request do
|
|||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Mobile iOS Authentication' do
|
||||
it 'redirects to iOS success path when signing in with iOS client header' do
|
||||
# Make a login request with the iOS client header (user NOT pre-signed in)
|
||||
post user_session_path, params: {
|
||||
user: { email: user.email, password: 'password123' }
|
||||
}, headers: {
|
||||
'X-Dawarich-Client' => 'ios',
|
||||
'Accept' => 'text/html'
|
||||
}
|
||||
|
||||
# Should redirect to iOS success endpoint after successful login
|
||||
# The redirect will include a token parameter generated by after_sign_in_path_for
|
||||
expect(response).to redirect_to(%r{auth/ios/success\?token=})
|
||||
expect(response.location).to include('token=')
|
||||
end
|
||||
|
||||
it 'stores iOS client header in session' do
|
||||
# Test that the header gets stored when accessing sign-in page
|
||||
get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' }
|
||||
|
||||
expect(session[:dawarich_client]).to eq('ios')
|
||||
end
|
||||
|
||||
it 'redirects to iOS success path using stored session value' do
|
||||
# Simulate iOS app accessing sign-in page first (stores header in session)
|
||||
get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' }
|
||||
|
||||
# Then sign-in POST request without header (relies on session)
|
||||
post user_session_path, params: {
|
||||
user: { email: user.email, password: 'password123' }
|
||||
}, headers: {
|
||||
'Accept' => 'text/html'
|
||||
}
|
||||
|
||||
# Should still redirect to iOS success endpoint using session value
|
||||
expect(response).to redirect_to(%r{auth/ios/success\?token=})
|
||||
expect(response.location).to include('token=')
|
||||
end
|
||||
|
||||
it 'returns plain text response for iOS success endpoint with token' do
|
||||
# Generate a test JWT token using the same service as the controller
|
||||
payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i }
|
||||
test_token = Subscription::EncodeJwtToken.new(
|
||||
payload, ENV['AUTH_JWT_SECRET_KEY']
|
||||
).call
|
||||
|
||||
get ios_success_path, params: { token: test_token }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(response.content_type).to include('text/plain')
|
||||
expect(response.body).to eq('Authentication successful! You can close this window.')
|
||||
end
|
||||
|
||||
it 'returns JSON response when no token is provided to iOS success endpoint' do
|
||||
get ios_success_path
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(response.content_type).to include('application/json')
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be true
|
||||
expect(json_response['message']).to eq('iOS authentication successful')
|
||||
expect(json_response['redirect_url']).to eq(root_url)
|
||||
end
|
||||
|
||||
it 'generates JWT token with correct payload for iOS authentication' do
|
||||
# Test JWT token generation directly using the same logic as after_sign_in_path_for
|
||||
payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i }
|
||||
|
||||
# Create JWT token using the same service
|
||||
token = Subscription::EncodeJwtToken.new(
|
||||
payload, ENV['AUTH_JWT_SECRET_KEY']
|
||||
).call
|
||||
|
||||
expect(token).to be_present
|
||||
|
||||
# Decode the token to verify the payload
|
||||
decoded_payload = JWT.decode(
|
||||
token,
|
||||
ENV['AUTH_JWT_SECRET_KEY'],
|
||||
true,
|
||||
{ algorithm: 'HS256' }
|
||||
).first
|
||||
|
||||
expect(decoded_payload['api_key']).to eq(user.api_key)
|
||||
expect(decoded_payload['exp']).to be_present
|
||||
end
|
||||
|
||||
it 'uses default path for non-iOS clients' do
|
||||
# Make a login request without iOS client header (user NOT pre-signed in)
|
||||
post user_session_path, params: {
|
||||
user: { email: user.email, password: 'password123' }
|
||||
}
|
||||
|
||||
# Should redirect to default path (not iOS success)
|
||||
expect(response).not_to redirect_to(%r{auth/ios/success})
|
||||
expect(response.location).not_to include('auth/ios/success')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,14 +7,26 @@ RSpec.describe Api::PointSerializer do
|
|||
subject(:serializer) { described_class.new(point).call }
|
||||
|
||||
let(:point) { create(:point) }
|
||||
let(:expected_json) { point.attributes.except(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) }
|
||||
let(:all_excluded) { Api::PointSerializer::EXCLUDED_ATTRIBUTES }
|
||||
let(:expected_json) do
|
||||
point.attributes.except(*all_excluded).tap do |attributes|
|
||||
# API serializer extracts coordinates from PostGIS geometry
|
||||
attributes['latitude'] = point.lat.to_s
|
||||
attributes['longitude'] = point.lon.to_s
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns JSON with correct attributes' do
|
||||
expect(serializer.to_json).to eq(expected_json.to_json)
|
||||
end
|
||||
|
||||
it 'does not include excluded attributes' do
|
||||
expect(serializer).not_to include(*Api::PointSerializer::EXCLUDED_ATTRIBUTES)
|
||||
expect(serializer).not_to include(*all_excluded)
|
||||
end
|
||||
|
||||
it 'extracts coordinates from PostGIS geometry' do
|
||||
expect(serializer['latitude']).to eq(point.lat.to_s)
|
||||
expect(serializer['longitude']).to eq(point.lon.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ RSpec.describe Api::UserSerializer do
|
|||
describe '#call' do
|
||||
subject(:serializer) { described_class.new(user).call }
|
||||
|
||||
let(:user) { create(:user, email: 'test@example.com', theme: 'dark') }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'returns JSON with correct user attributes' do
|
||||
expect(serializer[:user][:email]).to eq(user.email)
|
||||
|
|
@ -81,5 +81,61 @@ RSpec.describe Api::UserSerializer do
|
|||
expect(settings[:maps]).to eq({ 'distance_unit' => 'mi' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'subscription data' do
|
||||
context 'when not self-hosted (hosted instance)' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||
end
|
||||
|
||||
it 'includes subscription data' do
|
||||
expect(serializer).to have_key(:subscription)
|
||||
expect(serializer[:subscription]).to include(:status, :active_until)
|
||||
end
|
||||
|
||||
it 'returns correct subscription values' do
|
||||
subscription = serializer[:subscription]
|
||||
expect(subscription[:status]).to eq(user.status)
|
||||
expect(subscription[:active_until]).to eq(user.active_until)
|
||||
end
|
||||
|
||||
context 'with specific subscription values' do
|
||||
it 'serializes trial user status correctly' do
|
||||
# When not self-hosted, users start with trial status via start_trial callback
|
||||
test_user = create(:user)
|
||||
serializer_result = described_class.new(test_user).call
|
||||
subscription = serializer_result[:subscription]
|
||||
|
||||
expect(subscription[:status]).to eq('trial')
|
||||
expect(subscription[:active_until]).to be_within(1.second).of(7.days.from_now)
|
||||
end
|
||||
|
||||
it 'serializes subscription data with all expected fields' do
|
||||
test_user = create(:user)
|
||||
serializer_result = described_class.new(test_user).call
|
||||
subscription = serializer_result[:subscription]
|
||||
|
||||
expect(subscription).to include(:status, :active_until)
|
||||
expect(subscription[:status]).to be_a(String)
|
||||
expect(subscription[:active_until]).to be_a(ActiveSupport::TimeWithZone)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when self-hosted' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
|
||||
end
|
||||
|
||||
it 'does not include subscription data' do
|
||||
expect(serializer).not_to have_key(:subscription)
|
||||
end
|
||||
|
||||
it 'still includes user and settings data' do
|
||||
expect(serializer).to have_key(:user)
|
||||
expect(serializer[:user]).to include(:email, :theme, :settings)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe Areas::Visits::Create do
|
||||
describe '#call' do
|
||||
let(:user) { create(:user) }
|
||||
let!(:user) { create(:user) }
|
||||
let(:home_area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100) }
|
||||
let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) }
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do
|
|||
it 'creates points with correct data' do
|
||||
parser
|
||||
|
||||
expect(Point.all[6].lat).to eq(27.696576)
|
||||
expect(Point.all[6].lon).to eq(-97.376949)
|
||||
expect(Point.all[6].timestamp).to eq(1_693_180_140)
|
||||
expect(user.points[6].lat).to eq(27.696576)
|
||||
expect(user.points[6].lon).to eq(-97.376949)
|
||||
expect(user.points[6].timestamp).to eq(1_693_180_140)
|
||||
|
||||
expect(Point.last.lat).to eq(27.709617)
|
||||
expect(Point.last.lon).to eq(-97.375988)
|
||||
expect(Point.last.timestamp).to eq(1_693_180_320)
|
||||
expect(user.points.last.lat).to eq(27.709617)
|
||||
expect(user.points.last.lon).to eq(-97.375988)
|
||||
expect(user.points.last.timestamp).to eq(1_693_180_320)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,11 +57,13 @@ RSpec.describe Gpx::TrackImporter do
|
|||
it 'creates points with correct data' do
|
||||
parser
|
||||
|
||||
expect(Point.first.lat).to eq(37.1722103)
|
||||
expect(Point.first.lon).to eq(-3.55468)
|
||||
expect(Point.first.altitude).to eq(1066)
|
||||
expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i)
|
||||
expect(Point.first.velocity).to eq('2.9')
|
||||
point = user.points.first
|
||||
|
||||
expect(point.lat).to eq(37.1722103)
|
||||
expect(point.lon).to eq(-3.55468)
|
||||
expect(point.altitude).to eq(1066)
|
||||
expect(point.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i)
|
||||
expect(point.velocity).to eq('2.9')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -71,11 +73,13 @@ RSpec.describe Gpx::TrackImporter do
|
|||
it 'creates points with correct data' do
|
||||
parser
|
||||
|
||||
expect(Point.first.lat).to eq(10.758321212464024)
|
||||
expect(Point.first.lon).to eq(106.64234449272531)
|
||||
expect(Point.first.altitude).to eq(17)
|
||||
expect(Point.first.timestamp).to eq(1_730_626_211)
|
||||
expect(Point.first.velocity).to eq('2.8')
|
||||
point = user.points.first
|
||||
|
||||
expect(point.lat).to eq(10.758321212464024)
|
||||
expect(point.lon).to eq(106.64234449272531)
|
||||
expect(point.altitude).to eq(17)
|
||||
expect(point.timestamp).to eq(1_730_626_211)
|
||||
expect(point.velocity).to eq('2.8')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
117
spec/services/maps/bounds_calculator_spec.rb
Normal file
117
spec/services/maps/bounds_calculator_spec.rb
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Maps::BoundsCalculator do
|
||||
describe '.call' do
|
||||
subject(:calculate_bounds) do
|
||||
described_class.new(user:, start_date:, end_date:).call
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:start_date) { '2024-06-01T00:00:00Z' }
|
||||
let(:end_date) { '2024-06-30T23:59:59Z' }
|
||||
|
||||
context 'with valid user and date range' do
|
||||
before do
|
||||
# Create test points within the date range
|
||||
create(:point, user:, latitude: 40.6, longitude: -74.1,
|
||||
timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
|
||||
create(:point, user:, latitude: 40.8, longitude: -73.9,
|
||||
timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
|
||||
create(:point, user:, latitude: 40.7, longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns success with bounds data' do
|
||||
expect(calculate_bounds).to match(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
min_lat: 40.6,
|
||||
max_lat: 40.8,
|
||||
min_lng: -74.1,
|
||||
max_lng: -73.9,
|
||||
point_count: 3
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no points in date range' do
|
||||
before do
|
||||
# Create points outside the date range
|
||||
create(:point, user:, latitude: 40.7, longitude: -74.0,
|
||||
timestamp: Time.new(2024, 5, 15, 10, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns failure with no data message' do
|
||||
expect(calculate_bounds).to match(
|
||||
{
|
||||
success: false,
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no user' do
|
||||
let(:user) { nil }
|
||||
|
||||
it 'raises NoUserFoundError' do
|
||||
expect { calculate_bounds }.to raise_error(
|
||||
Maps::BoundsCalculator::NoUserFoundError,
|
||||
'No user found'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no start date' do
|
||||
let(:start_date) { nil }
|
||||
|
||||
it 'raises NoDateRangeError' do
|
||||
expect { calculate_bounds }.to raise_error(
|
||||
Maps::BoundsCalculator::NoDateRangeError,
|
||||
'No date range specified'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no end date' do
|
||||
let(:end_date) { nil }
|
||||
|
||||
it 'raises NoDateRangeError' do
|
||||
expect { calculate_bounds }.to raise_error(
|
||||
Maps::BoundsCalculator::NoDateRangeError,
|
||||
'No date range specified'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid date parsing' do
|
||||
let(:start_date) { 'invalid-date' }
|
||||
|
||||
it 'raises ArgumentError for invalid dates' do
|
||||
expect { calculate_bounds }.to raise_error(ArgumentError, 'Invalid date format: invalid-date')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with timestamp format dates' do
|
||||
let(:start_date) { 1_717_200_000 }
|
||||
let(:end_date) { 1_719_791_999 }
|
||||
|
||||
before do
|
||||
create(:point, user:, latitude: 41.0, longitude: -74.5,
|
||||
timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
|
||||
end
|
||||
|
||||
it 'handles timestamp format correctly' do
|
||||
result = calculate_bounds
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:data][:point_count]).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue