Merge branch 'dev' into dependabot/bundler/ffaker-2.25.0

This commit is contained in:
Evgenii Burmakin 2025-09-26 19:55:46 +02:00 committed by GitHub
commit 3adfcc03c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 3341 additions and 1622 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1 +1 @@
3.4.1
3.4.6

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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

View 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="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" 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="&lt;Path&gt;" 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="&lt;Group&gt;">
<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

View file

@ -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"
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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

View file

@ -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

View file

@ -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.',

View 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

View file

@ -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,
source: :user_data_archive
)
import = current_user.imports.build(
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

View file

@ -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: {

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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
}

View file

@ -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,8 +202,8 @@ 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: "&copy; <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: "&copy; <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);
}
}
}

View file

@ -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>
`;
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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
}
})
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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 };

View 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'
};
}
}

View 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'
});
}

View file

@ -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';

View 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
})

View 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

View file

@ -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
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
# 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 =
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.strip.start_with?('<gpx')
) && content_to_check.include?('<gpx')
end
def owntracks_file?
@ -123,11 +124,11 @@ class Imports::SourceDetector
# Check for specific OwnTracks line format in content
content_to_check = if file_path && File.exist?(file_path)
# For OwnTracks, read first few lines only
File.open(file_path, 'r') { |f| f.read(2048) }
else
file_content
end
# For OwnTracks, read first few lines only
File.open(file_path, 'r') { |f| f.read(2048) }
else
file_content
end
content_to_check.lines.any? { |line| line.include?('"_type":"location"') }
end
@ -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

View file

@ -48,15 +48,16 @@ module LocationSearch
last_point = sorted_points.last
# Calculate visit duration
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
15 # minutes
end
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
15 # minutes
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')}"

View 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

View 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

View 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

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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

View 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

View file

@ -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">

View file

@ -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 %>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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" %>

View file

@ -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.
</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' %>.
</p>
<div class="modal-action">
<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>
</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="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 %>

View file

@ -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 %>"

View file

@ -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) %>">
<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>
<% 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>

View file

@ -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>

View file

@ -1,185 +1,163 @@
<!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 %>
<div class="container mx-auto px-4 py-8">
<!-- Monthly Digest Header -->
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background-image: url('<%= month_bg_image(@stat) %>');">
<div class="hero-overlay bg-opacity-60"></div>
<div class="hero-content text-center py-8">
<div class="max-w-lg">
<h1 class="text-4xl font-bold flex items-center justify-center gap-2">
<%= "#{icon month_icon(@stat)} #{Date::MONTHNAMES[@month]} #{@year}".html_safe %>
</h1>
<p class="pt-6 pb-2">Monthly Digest</p>
</div>
</div>
</div>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<div class="stats shadow mx-auto mb-8 w-full">
<div class="stat place-items-center text-center">
<div class="stat-title">Distance traveled</div>
<div class="stat-value"><%= distance_traveled(@user, @stat) %></div>
<div class="stat-desc">Total distance for this month</div>
</div>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<div class="stat place-items-center text-center">
<div class="stat-title">Active days</div>
<div class="stat-value text-secondary">
<%= active_days(@stat) %>
</div>
<div class="stat-desc text-secondary">
Days with tracked activity
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<div class="stat place-items-center text-center">
<div class="stat-title">Countries visited</div>
<div class="stat-value">
<%= countries_visited(@stat) %>
</div>
<div class="stat-desc">
Different countries
</div>
</div>
</div>
<% if @self_hosted %>
<!-- ProtomapsL for vector tiles -->
<script src="https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js"></script>
<% end %>
</head>
<body data-theme="dark">
<div class="min-h-screen bg-base-100 mx-auto">
<div class="container mx-auto px-4 py-8">
<!-- Monthly Digest Header -->
<div class="hero text-white rounded-lg shadow-lg mb-8" style="background-image: url('<%= month_bg_image(@stat) %>');">
<div class="hero-overlay bg-opacity-60"></div>
<div class="hero-content text-center py-8">
<div class="max-w-lg">
<h1 class="text-4xl font-bold flex items-center justify-center gap-2">
<%= "#{icon month_icon(@stat)} #{Date::MONTHNAMES[@month]} #{@year}".html_safe %>
</h1>
<p class="pt-6 pb-2">Monthly Digest</p>
<!-- 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>
<div class="stats shadow mx-auto mb-8 w-full">
<div class="stat place-items-center text-center">
<div class="stat-title">Distance traveled</div>
<div class="stat-value"><%= distance_traveled(@user, @stat) %></div>
<div class="stat-desc">Total distance for this month</div>
</div>
<!-- Hexagon Map Container -->
<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>
<div class="stat place-items-center text-center">
<div class="stat-title">Active days</div>
<div class="stat-value text-secondary">
<%= active_days(@stat) %>
<!-- Loading overlay -->
<div id="map-loading" class="absolute inset-0 bg-base-200 bg-opacity-80 flex items-center justify-center z-50">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-sm mt-2 text-base-content">Loading hexagons...</p>
</div>
<div class="stat-desc text-secondary">
Days with tracked activity
</div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title">Countries visited</div>
<div class="stat-value">
<%= countries_visited(@stat) %>
</div>
<div class="stat-desc">
Different countries
</div>
</div>
</div>
<!-- Map Summary - Hexagon View -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body p-0">
<!-- Hexagon Map Container -->
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
<div id="public-monthly-stats-map" class="w-full h-full"
data-controller="public-stat-map"
data-public-stat-map-year-value="<%= @year %>"
data-public-stat-map-month-value="<%= @month %>"
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
<!-- Loading overlay -->
<div id="map-loading" class="absolute inset-0 bg-base-200 bg-opacity-80 flex items-center justify-center z-50">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-sm mt-2 text-base-content">Loading hexagons...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Daily Activity Chart -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'trending-up' %> Daily Activity
</h2>
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
<%= column_chart(
@stat.daily_distance.map { |day, distance_meters|
[day, Stat.convert_distance(distance_meters, 'km').round]
},
height: '200px',
suffix: " km",
xtitle: 'Day',
ytitle: 'Distance',
colors: [
'#570df8', '#f000b8', '#ffea00',
'#00d084', '#3abff8', '#ff5724',
'#8e24aa', '#3949ab', '#00897b',
'#d81b60', '#5e35b1', '#039be5',
'#43a047', '#f4511e', '#6d4c41',
'#757575', '#546e7a', '#d32f2f'
],
library: {
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(0,0,0,0.1)' }
},
y: {
grid: { color: 'rgba(0,0,0,0.1)' }
}
}
}
) %>
</div>
<div class="text-sm opacity-70 text-center mt-2">
Peak day: <%= peak_day(@stat) %> • Quietest week: <%= quietest_week(@stat) %>
</div>
</div>
</div>
<!-- Countries & Cities - General Info Only -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<% @stat.toponyms.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold"><%= country['country'] %></span>
<span class="text-sm"><%= country['cities'].length %> cities</span>
</div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 20) %>" max="100"></progress>
</div>
<% end %>
</div>
<div class="divider"></div>
<div class="flex flex-wrap gap-2">
<span class="text-sm font-medium">Cities visited:</span>
<% @stat.toponyms.each do |country| %>
<% country['cities'].first(5).each do |city| %>
<div class="badge badge-outline"><%= city['city'] %></div>
<% end %>
<% if country['cities'].length > 5 %>
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center py-8">
<div class="text-sm text-gray-500">
Powered by <a href="https://dawarich.app" class="link link-primary" target="_blank">Dawarich</a>, your personal memories mapper.
</div>
</div>
</div>
</div>
<!-- Map is now handled by the Stimulus controller -->
</body>
</html>
<!-- Daily Activity Chart -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'trending-up' %> Daily Activity
</h2>
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
<%= column_chart(
@stat.daily_distance.map { |day, distance_meters|
[day, Stat.convert_distance(distance_meters, 'km').round]
},
height: '200px',
suffix: " km",
xtitle: 'Day',
ytitle: 'Distance',
colors: [
'#570df8', '#f000b8', '#ffea00',
'#00d084', '#3abff8', '#ff5724',
'#8e24aa', '#3949ab', '#00897b',
'#d81b60', '#5e35b1', '#039be5',
'#43a047', '#f4511e', '#6d4c41',
'#757575', '#546e7a', '#d32f2f'
],
library: {
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(0,0,0,0.1)' }
},
y: {
grid: { color: 'rgba(0,0,0,0.1)' }
}
}
}
) %>
</div>
<div class="text-sm opacity-70 text-center mt-2">
Peak day: <%= peak_day(@stat) %> • Quietest week: <%= quietest_week(@stat) %>
</div>
</div>
</div>
<!-- Countries & Cities - General Info Only -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<% @stat.toponyms.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold"><%= country['country'] %></span>
<span class="text-sm"><%= country['cities'].length %> cities</span>
</div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 20) %>" max="100"></progress>
</div>
<% end %>
</div>
<div class="divider"></div>
<div class="flex flex-wrap gap-2">
<span class="text-sm font-medium">Cities visited:</span>
<% @stat.toponyms.each do |country| %>
<% country['cities'].first(5).each do |city| %>
<div class="badge badge-outline"><%= city['city'] %></div>
<% end %>
<% if country['cities'].length > 5 %>
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center py-8">
<div class="text-sm text-gray-500">
Powered by <a href="https://dawarich.app" class="link link-primary" target="_blank">Dawarich</a>, your personal memories mapper.
</div>
</div>
</div>

View file

@ -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

View file

@ -23,4 +23,4 @@ production:
staging:
<<: *default
database: <%= ENV['DATABASE_NAME'] || 'dawarich_staging' %>
database: <%= ENV['DATABASE_NAME'] || 'dawarich_production' %>

View file

@ -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'

View 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

View file

@ -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

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
Mime::Type.register 'application/geo+json', :geojson
Mime::Type.register 'application/manifest+json', :webmanifest

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_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"

View file

@ -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 ../. ./

View file

@ -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 ../. ./

View 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

View file

@ -1,7 +1,8 @@
{
"webcredentials": {
"apps": [
"2A275P77DQ.app.dawarich.Dawarich"
"2A275P77DQ.app.dawarich.Dawarich",
"3DJN84WAS8.app.dawarich.Dawarich"
]
}
}

19
public/site.webmanifest Normal file
View 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"
}

View file

@ -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

View file

@ -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 }

View file

@ -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

View file

@ -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

View file

@ -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,

View 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

View file

@ -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

View file

@ -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

View file

@ -31,7 +31,7 @@ RSpec.describe Import, type: :model do
describe 'file size validation' do
context 'when user is a trial user' do
let(:user) do
let(:user) do
user = create(:user)
user.update!(status: :trial)
user
@ -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

View file

@ -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

View file

@ -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

View file

@ -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
it 'returns empty feature collection' do
get '/api/v1/maps/hexagons', params: valid_params, headers: empty_headers
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['error']).to include('Missing required parameters')
expect(json_response['error']).to include('min_lon')
json_response = JSON.parse(response.body)
expect(json_response['type']).to eq('FeatureCollection')
expect(json_response['features']).to be_empty
end
end
it 'handles service validation errors' do
invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
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: invalid_params, headers: headers
get '/api/v1/maps/hexagons', params: dateline_params, headers: headers
expect(response).to have_http_status(:bad_request)
end
# Should either succeed or return appropriate error, not crash
expect([200, 400, 500]).to include(response.status)
end
it 'uses custom hex_size when provided' do
custom_params = valid_params.merge(hex_size: 500)
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: custom_params, headers: headers
get '/api/v1/maps/hexagons', params: polar_params, headers: headers
expect(response).to have_http_status(:success)
# 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

View file

@ -6,9 +6,9 @@ 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")
.with(headers: { 'Accept'=>'*/*', 'Accept-Encoding'=>/.*/,
'Host'=>'api.github.com', 'User-Agent'=>/.*/})
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: {})
end
@ -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

View file

@ -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

View file

@ -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

View file

@ -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) }

View file

@ -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

View file

@ -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

View 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