Merge pull request #1763 from Freika/feature/precalculating-month-stats-data

Precalculate hexagons for stats
This commit is contained in:
Evgenii Burmakin 2025-09-20 14:46:39 +02:00 committed by GitHub
commit 482c0928fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1961 additions and 1340 deletions

View file

@ -4,6 +4,21 @@ 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.
## 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.
# [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'

View file

@ -172,6 +172,12 @@ GEM
railties (>= 6.1.0)
fakeredis (0.1.4)
ffaker (2.24.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
@ -543,6 +553,7 @@ DEPENDENCIES
geocoder!
gpx
groupdate
h3 (~> 3.7)
httparty
importmap-rails
jwt (~> 2.8)

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

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

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

@ -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,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,8 +42,8 @@ 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() {
@ -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

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

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

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

@ -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,21 +1,94 @@
<% if user_signed_in? %>
<div data-controller="onboarding-modal"
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
data-onboarding-modal-showable-value="true">
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Start tracking your location!</h3>
<p class="py-4">
To start tracking your location and putting it on the map, you need to configure your mobile application.
<div class="modal-box max-w-2xl bg-base-200">
<!-- Header -->
<div class="text-center mb-6">
<h3 class="text-2xl font-bold text-primary mb-2 flex items-center justify-center gap-2">
<%= icon 'goal' %> Start Tracking Your Location!</h3>
<p class="text-base-content/70">
Welcome to Dawarich! Let's get you set up to start tracking and visualizing your location data.
</p>
<p>
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
</div>
<!-- Steps -->
<div class="space-y-6">
<!-- Step 1: Download App -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="flex items-center gap-3 mb-3">
<div class="badge badge-primary badge-lg">1</div>
<h4 class="text-lg font-semibold">Download the Official App</h4>
</div>
<p class="text-sm text-base-content/80 mb-4">
Get the official Dawarich app from the App Store to start tracking your location.
</p>
<div class="modal-action">
<div class="flex justify-center">
<%= link_to 'https://apps.apple.com/de/app/dawarich/id6739544999?itscg=30200&itsct=apps_box_badge&mttnsubad=6739544999',
class: 'inline-block rounded-lg border-2 border-transparent hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-300 ease-in-out transform hover:scale-105' do %>
<%= image_tag 'Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg',
class: 'h-12 transition-opacity duration-300' %>
<% end %>
</div>
</div>
<div class="card-body p-4">
<div class="flex items-center gap-3 mb-3">
<div class="badge badge-primary badge-lg">2</div>
<h4 class="text-lg font-semibold">Scan QR Code to Connect</h4>
</div>
<p class="text-sm text-base-content/80 mb-4">
Scan this QR code with the Dawarich app to automatically configure your connection.
</p>
<div class="flex justify-center">
<div class="bg-white p-3 rounded-lg shadow-inner">
<%= api_key_qr_code(current_user, size: 3) %>
</div>
</div>
</div>
<div class="card-body p-4">
<div class="flex items-center gap-3 mb-3">
<div class="badge badge-secondary badge-lg">3</div>
<h4 class="text-lg font-semibold">Manual Setup (Alternative)</h4>
</div>
<p class="text-sm text-base-content/80">
Alternatively, you can manually grab your API key from
<%= link_to 'Settings', settings_path, class: 'link link-primary font-medium' %>
and follow the setup instructions in our
<%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding',
class: 'link link-primary font-medium', target: '_blank', rel: 'noopener' %>.
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-action mt-8">
<div class="text-center flex-1">
<p class="text-xs text-base-content/60 mb-4">
Need help? Check out our
<%= link_to 'documentation', 'https://dawarich.app/docs/category/tutorials?utm_source=app&utm_medium=referral&utm_campaign=onboarding',
class: 'link link-primary text-xs', target: '_blank', rel: 'noopener' %>
for more guidance.
</p>
</div>
<form method="dialog">
<button class="btn">Close</button>
<button class="btn btn-primary btn-wide">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Got it, let's start!
</button>
</form>
</div>
</div>
<!-- Modal backdrop -->
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
<% end %>

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,33 +1,4 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shared Stats - <%= Date::MONTHNAMES[@month] %> <%= @year %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<% if @self_hosted %>
<!-- ProtomapsL for vector tiles -->
<script src="https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js"></script>
<% end %>
</head>
<body data-theme="dark">
<div class="min-h-screen bg-base-100 mx-auto">
<div class="container mx-auto px-4 py-8">
<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>
@ -72,14 +43,27 @@
<!-- 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">📍 Location Hexagons</h3>
<% if @hexagons_available %>
<div class="badge badge-success badge-sm">H3 Enhanced</div>
<% end %>
</div>
</div>
</div>
<!-- Hexagon Map Container -->
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
<div class="w-full h-96 border border-base-300 relative overflow-hidden">
<div id="public-monthly-stats-map" class="w-full h-full"
data-controller="public-stat-map"
data-public-stat-map-year-value="<%= @year %>"
data-public-stat-map-month-value="<%= @month %>"
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
data-public-stat-map-hexagons-available-value="<%= @hexagons_available.to_s %>"
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
<!-- Loading overlay -->
@ -177,9 +161,4 @@
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>
</div>

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

@ -0,0 +1,12 @@
# frozen_string_literal: true
class AddH3HexIdsToStats < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_column :stats, :h3_hex_ids, :jsonb, default: {}
add_index :stats, :h3_hex_ids, using: :gin,
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
algorithm: :concurrently
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

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

@ -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
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to include('Missing required parameters')
expect(json_response['error']).to include('min_lon')
end
it 'handles service validation errors' do
invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
expect(response).to have_http_status(:bad_request)
end
it 'uses custom hex_size when provided' do
custom_params = valid_params.merge(hex_size: 500)
get '/api/v1/maps/hexagons', params: custom_params, headers: headers
it 'returns empty feature collection' do
get '/api/v1/maps/hexagons', params: valid_params, headers: empty_headers
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['type']).to eq('FeatureCollection')
expect(json_response['features']).to be_empty
end
end
context 'with edge case coordinates' do
it 'handles coordinates at dateline' do
dateline_params = valid_params.merge(
min_lon: 179.0, max_lon: -179.0,
min_lat: -1.0, max_lat: 1.0
)
get '/api/v1/maps/hexagons', params: dateline_params, headers: headers
# Should either succeed or return appropriate error, not crash
expect([200, 400, 500]).to include(response.status)
end
it 'handles polar coordinates' do
polar_params = valid_params.merge(
min_lon: -180.0, max_lon: 180.0,
min_lat: 85.0, max_lat: 90.0
)
get '/api/v1/maps/hexagons', params: polar_params, headers: headers
# Should either succeed or return appropriate error, not crash
expect([200, 400, 500]).to include(response.status)
end
end
end
@ -157,6 +169,87 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
expect(json_response['error']).to eq('Shared stats not found or no longer available')
end
end
context 'with pre-calculated hexagon centers' do
let(:pre_calculated_centers) do
{
'8a1fb46622dffff' => [5, 1_717_200_000, 1_717_203_600], # 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,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)

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

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::DateParameterCoercer do
describe '.call' do
subject(:coerce_date) { described_class.new(param).call }
context 'with integer parameter' do
let(:param) { 1_717_200_000 }
it 'returns the integer unchanged' do
expect(coerce_date).to eq(1_717_200_000)
end
end
context 'with numeric string parameter' do
let(:param) { '1717200000' }
it 'converts to integer' do
expect(coerce_date).to eq(1_717_200_000)
end
end
context 'with ISO date string parameter' do
let(:param) { '2024-06-01T00:00:00Z' }
it 'parses and converts to timestamp' do
expected_timestamp = Time.parse('2024-06-01T00:00:00Z').to_i
expect(coerce_date).to eq(expected_timestamp)
end
end
context 'with date string parameter' do
let(:param) { '2024-06-01' }
it 'parses and converts to timestamp' do
expected_timestamp = Time.parse('2024-06-01').to_i
expect(coerce_date).to eq(expected_timestamp)
end
end
context 'with invalid date string' do
let(:param) { 'invalid-date' }
it 'raises InvalidDateFormatError' do
expect { coerce_date }.to raise_error(
Maps::DateParameterCoercer::InvalidDateFormatError,
'Invalid date format: invalid-date'
)
end
end
context 'with nil parameter' do
let(:param) { nil }
it 'converts to 0' do
expect(coerce_date).to eq(0)
end
end
context 'with float parameter' do
let(:param) { 1_717_200_000.5 }
it 'converts to integer' do
expect(coerce_date).to eq(1_717_200_000)
end
end
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonCenterManager do
describe '.call' do
subject(:manage_centers) { described_class.new(stat:, user:).call }
let(:user) { create(:user) }
let(:target_user) { user }
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) { create(:stat, user:, year: 2024, month: 6, h3_hex_ids: pre_calculated_centers) }
it 'returns success with pre-calculated data' do
result = manage_centers
expect(result[:success]).to be true
expect(result[:pre_calculated]).to be true
expect(result[:data]['type']).to eq('FeatureCollection')
expect(result[:data]['features'].length).to eq(3)
expect(result[:data]['metadata']['pre_calculated']).to be true
expect(result[:data]['metadata']['count']).to eq(3)
expect(result[:data]['metadata']['user_id']).to eq(target_user.id)
end
it 'generates proper hexagon features from centers' do
result = manage_centers
features = result[:data]['features']
features.each_with_index do |feature, index|
expect(feature['type']).to eq('Feature')
expect(feature['id']).to eq(index + 1)
expect(feature['geometry']['type']).to eq('Polygon')
expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing
properties = feature['properties']
expect(properties['hex_id']).to eq(index + 1)
expect(properties['earliest_point']).to be_present
expect(properties['latest_point']).to be_present
end
end
end
context 'with no stat' do
let(:stat) { nil }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
context 'with stat but no hexagon_centers' do
let(:stat) { create(:stat, user:, year: 2024, month: 6, h3_hex_ids: nil) }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
context 'with empty hexagon_centers' do
let(:stat) { create(:stat, user:, year: 2024, month: 6, h3_hex_ids: []) }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
end
end

View file

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonPolygonGenerator do
describe '.call' do
subject(:generate_polygon) do
described_class.new(h3_index: h3_index).call
end
# Valid H3 index for NYC area (resolution 6)
let(:h3_index) { '8a1fb46622dffff' }
it 'returns a polygon geometry' do
result = generate_polygon
expect(result['type']).to eq('Polygon')
expect(result['coordinates']).to be_an(Array)
expect(result['coordinates'].length).to eq(1) # One ring
end
it 'generates a hexagon with 7 coordinate pairs (6 vertices + closing)' do
result = generate_polygon
coordinates = result['coordinates'].first
expect(coordinates.length).to eq(7) # 6 vertices + closing vertex
expect(coordinates.first).to eq(coordinates.last) # Closed polygon
end
it 'generates unique vertices' do
result = generate_polygon
coordinates = result['coordinates'].first
# Remove the closing vertex for uniqueness check
unique_vertices = coordinates[0..5]
expect(unique_vertices.uniq.length).to eq(6) # All vertices should be unique
end
it 'generates vertices in proper [lng, lat] format' do
result = generate_polygon
coordinates = result['coordinates'].first
coordinates.each do |vertex|
lng, lat = vertex
expect(lng).to be_a(Float)
expect(lat).to be_a(Float)
expect(lng).to be_between(-180, 180)
expect(lat).to be_between(-90, 90)
end
end
context 'with hex string index' do
let(:h3_index) { '8a1fb46622dffff' }
it 'handles hex string format' do
result = generate_polygon
expect(result['type']).to eq('Polygon')
expect(result['coordinates'].first.length).to eq(7)
end
end
context 'with integer index' do
let(:h3_index) { 0x8a1fb46622dffff }
it 'handles integer format' do
result = generate_polygon
expect(result['type']).to eq('Polygon')
expect(result['coordinates'].first.length).to eq(7)
end
end
context 'when H3 operations fail' do
before do
allow(H3).to receive(:to_boundary).and_raise(StandardError, 'H3 error')
end
it 'raises the H3 error' do
expect { generate_polygon }.to raise_error(StandardError, 'H3 error')
end
end
context 'with invalid H3 index' do
let(:h3_index) { nil }
it 'raises an error for invalid index' do
expect { generate_polygon }.to raise_error(TypeError)
end
end
end
end

View file

@ -0,0 +1,137 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonRequestHandler do
describe '.call' do
subject(:handle_request) do
described_class.new(
params: params,
user: user,
stat: stat,
start_date: start_date,
end_date: end_date
).call
end
let(:user) { create(:user) }
context 'with authenticated user but no pre-calculated data' do
let(:stat) { nil }
let(:start_date) { '2024-06-01T00:00:00Z' }
let(:end_date) { '2024-06-30T23:59:59Z' }
let(:params) do
ActionController::Parameters.new(
{
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8,
start_date: start_date,
end_date: end_date
}
)
end
it 'returns empty feature collection when no pre-calculated data' do
result = handle_request
expect(result).to be_a(Hash)
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to eq([])
expect(result['metadata']['hexagon_count']).to eq(0)
expect(result['metadata']['source']).to eq('pre_calculated')
end
end
context 'with public sharing UUID and pre-calculated centers' do
let(:pre_calculated_centers) do
[
['8a1fb46622dffff', 5, 1_717_200_000, 1_717_203_600],
['8a1fb46622e7fff', 3, 1_717_210_000, 1_717_213_600]
]
end
let(:stat) do
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
h3_hex_ids: pre_calculated_centers)
end
let(:start_date) { Date.new(2024, 6, 1).beginning_of_day.iso8601 }
let(:end_date) { Date.new(2024, 6, 1).end_of_month.end_of_day.iso8601 }
let(:params) do
ActionController::Parameters.new(
{
uuid: stat.sharing_uuid,
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8
}
)
end
it 'returns pre-calculated hexagon data' do
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features'].length).to eq(2)
expect(result['metadata']['pre_calculated']).to be true
expect(result['metadata']['user_id']).to eq(user.id)
end
end
context 'with public sharing UUID but no pre-calculated centers' do
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
let(:start_date) { Date.new(2024, 6, 1).beginning_of_day.iso8601 }
let(:end_date) { Date.new(2024, 6, 1).end_of_month.end_of_day.iso8601 }
let(:params) do
ActionController::Parameters.new(
{
uuid: stat.sharing_uuid,
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8
}
)
end
it 'returns empty feature collection when no pre-calculated centers' do
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to eq([])
expect(result['metadata']['hexagon_count']).to eq(0)
expect(result['metadata']['source']).to eq('pre_calculated')
end
end
context 'with stat containing empty h3_hex_ids data' do
let(:stat) do
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
h3_hex_ids: {})
end
let(:start_date) { Date.new(2024, 6, 1).beginning_of_day.iso8601 }
let(:end_date) { Date.new(2024, 6, 1).end_of_month.end_of_day.iso8601 }
let(:params) do
ActionController::Parameters.new(
{
uuid: stat.sharing_uuid,
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8
}
)
end
it 'returns empty feature collection for empty data' do
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to eq([])
expect(result['metadata']['hexagon_count']).to eq(0)
expect(result['metadata']['source']).to eq('pre_calculated')
end
end
end
end

View file

@ -23,7 +23,7 @@ RSpec.describe OwnTracks::Importer do
it 'correctly writes attributes' do
parser
point = Point.first
point = user.points.first
expect(point.lonlat.x).to be_within(0.001).of(13.332)
expect(point.lonlat.y).to be_within(0.001).of(52.225)
expect(point.attributes.except('lonlat')).to include(
@ -75,7 +75,7 @@ RSpec.describe OwnTracks::Importer do
it 'correctly converts speed' do
parser
expect(Point.first.velocity).to eq('1.4')
expect(user.points.first.velocity).to eq('1.4')
end
end
@ -85,12 +85,6 @@ RSpec.describe OwnTracks::Importer do
it 'creates points' do
expect { parser }.to change { Point.count }.by(9)
end
it 'correctly writes attributes' do
parser
point = Point.first
end
end
end
end

View file

@ -30,15 +30,18 @@ RSpec.describe Photos::Importer do
it 'creates points with correct attributes' do
service
expect(Point.first.lat.to_f).to eq(59.0000)
expect(Point.first.lon.to_f).to eq(30.0000)
expect(Point.first.timestamp).to eq(978_296_400)
expect(Point.first.import_id).to eq(import.id)
first_point = user.points.first
second_point = user.points.second
expect(Point.second.lat.to_f).to eq(55.0001)
expect(Point.second.lon.to_f).to eq(37.0001)
expect(Point.second.timestamp).to eq(978_296_400)
expect(Point.second.import_id).to eq(import.id)
expect(first_point.lat.to_f).to eq(59.0000)
expect(first_point.lon.to_f).to eq(30.0000)
expect(first_point.timestamp).to eq(978_296_400)
expect(first_point.import_id).to eq(import.id)
expect(second_point.lat.to_f).to eq(55.0001)
expect(second_point.lon.to_f).to eq(37.0001)
expect(second_point.timestamp).to eq(978_296_400)
expect(second_point.import_id).to eq(import.id)
end
end

View file

@ -0,0 +1,123 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Stats::HexagonCalculator do
describe '#call' do
subject(:calculate_hexagons) do
described_class.new(user.id, year, month).call(h3_resolution: h3_resolution)
end
let(:user) { create(:user) }
let(:year) { 2024 }
let(:month) { 1 }
let(:h3_resolution) { 8 }
context 'when there are no points' do
it 'returns empty array' do
expect(calculate_hexagons).to eq([])
end
end
context 'when there are points' do
let(:timestamp1) { DateTime.new(year, month, 1, 12).to_i }
let(:timestamp2) { DateTime.new(year, month, 1, 13).to_i }
let!(:import) { create(:import, user:) }
let!(:point1) do
create(:point,
user:,
import:,
timestamp: timestamp1,
lonlat: 'POINT(14.452712811406352 52.107902115161316)')
end
let!(:point2) do
create(:point,
user:,
import:,
timestamp: timestamp2,
lonlat: 'POINT(14.453712811406352 52.108902115161316)')
end
it 'returns H3 hexagon data' do
result = calculate_hexagons
expect(result).to be_an(Array)
expect(result).not_to be_empty
# Each record should have: [h3_index_string, point_count, earliest_timestamp, latest_timestamp]
result.each do |record|
expect(record).to be_an(Array)
expect(record.size).to eq(4)
expect(record[0]).to be_a(String) # H3 index as hex string
expect(record[1]).to be_a(Integer) # Point count
expect(record[2]).to be_a(Integer) # Earliest timestamp
expect(record[3]).to be_a(Integer) # Latest timestamp
end
end
it 'aggregates points correctly' do
result = calculate_hexagons
total_points = result.sum { |record| record[1] }
expect(total_points).to eq(2)
end
context 'when H3 raises an error' do
before do
allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error')
end
it 'raises PostGISError' do
expect do
calculate_hexagons
end.to raise_error(Stats::HexagonCalculator::PostGISError, /Failed to calculate H3 hexagon centers/)
end
it 'reports the exception' do
expect(ExceptionReporter).to receive(:call) if defined?(ExceptionReporter)
expect { calculate_hexagons }.to raise_error(Stats::HexagonCalculator::PostGISError)
end
end
end
end
describe '#calculate_h3_hex_ids' do
subject(:calculate_hex_ids) { described_class.new(user.id, year, month).calculate_h3_hex_ids }
let(:user) { create(:user) }
let(:year) { 2024 }
let(:month) { 1 }
context 'when there are no points' do
it 'returns empty hash' do
expect(calculate_hex_ids).to eq({})
end
end
context 'when there are points' do
let(:timestamp1) { DateTime.new(year, month, 1, 12).to_i }
let!(:import) { create(:import, user:) }
let!(:point1) do
create(:point,
user:,
import:,
timestamp: timestamp1,
lonlat: 'POINT(14.452712811406352 52.107902115161316)')
end
it 'returns hash with H3 hex IDs' do
result = calculate_hex_ids
expect(result).to be_a(Hash)
expect(result).not_to be_empty
result.each do |h3_index, data|
expect(h3_index).to be_a(String)
expect(data).to be_an(Array)
expect(data.size).to eq(3) # [count, earliest, latest]
end
end
end
end
end

View file

@ -15,22 +15,20 @@ RSpec.describe 'Map Interaction', type: :system do
# Create a series of points that form a route
[
create(:point, user: user,
lonlat: "POINT(13.404954 52.520008)",
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user,
lonlat: "POINT(13.405954 52.521008)",
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user,
lonlat: "POINT(13.406954 52.522008)",
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user,
lonlat: "POINT(13.407954 52.523008)",
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
describe 'Map page interaction' do
context 'when user is signed in' do
include_context 'authenticated map user'
@ -127,7 +125,7 @@ RSpec.describe 'Map Interaction', type: :system do
# The calendar panel JavaScript interaction is complex and may not work
# reliably in headless test environment, but the button should be functional
puts "Note: Calendar button is functional. Panel interaction may require manual testing."
puts 'Note: Calendar button is functional. Panel interaction may require manual testing.'
end
end
@ -207,28 +205,30 @@ RSpec.describe 'Map Interaction', type: :system do
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
puts "Note: Polyline popup interaction could not be triggered in test environment"
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
context 'with miles distance unit' do
let(:user_with_miles) { create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') }
let(:user_with_miles) do
create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123')
end
let!(:points_for_miles_user) do
# Create a series of points that form a route for the miles user
[
create(:point, user: user_with_miles,
lonlat: "POINT(13.404954 52.520008)",
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_miles,
lonlat: "POINT(13.405954 52.521008)",
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_miles,
lonlat: "POINT(13.406954 52.522008)",
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_miles,
lonlat: "POINT(13.407954 52.523008)",
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
@ -280,7 +280,7 @@ RSpec.describe 'Map Interaction', type: :system do
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
puts "Note: Polyline popup interaction could not be triggered in test environment"
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
@ -288,22 +288,24 @@ RSpec.describe 'Map Interaction', type: :system do
context 'polyline popup content' do
context 'with km distance unit' do
let(:user_with_km) { create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123') }
let(:user_with_km) do
create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123')
end
let!(:points_for_km_user) do
# Create a series of points that form a route for the km user
[
create(:point, user: user_with_km,
lonlat: "POINT(13.404954 52.520008)",
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_km,
lonlat: "POINT(13.405954 52.521008)",
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_km,
lonlat: "POINT(13.406954 52.522008)",
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_km,
lonlat: "POINT(13.407954 52.523008)",
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
@ -356,28 +358,30 @@ RSpec.describe 'Map Interaction', type: :system do
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
puts "Note: Polyline popup interaction could not be triggered in test environment"
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
context 'with miles distance unit' do
let(:user_with_miles) { create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') }
let(:user_with_miles) do
create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123')
end
let!(:points_for_miles_user) do
# Create a series of points that form a route for the miles user
[
create(:point, user: user_with_miles,
lonlat: "POINT(13.404954 52.520008)",
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_miles,
lonlat: "POINT(13.405954 52.521008)",
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_miles,
lonlat: "POINT(13.406954 52.522008)",
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_miles,
lonlat: "POINT(13.407954 52.523008)",
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
@ -429,7 +433,7 @@ RSpec.describe 'Map Interaction', type: :system do
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
puts "Note: Polyline popup interaction could not be triggered in test environment"
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
@ -710,13 +714,13 @@ RSpec.describe 'Map Interaction', type: :system do
it 'allows year selection and month navigation' do
# This test is skipped due to calendar panel JavaScript interaction issues
# The calendar button exists but the panel doesn't open reliably in test environment
skip "Calendar panel JavaScript interaction needs debugging"
skip 'Calendar panel JavaScript interaction needs debugging'
end
it 'displays visited cities information' do
# This test is skipped due to calendar panel JavaScript interaction issues
# The calendar button exists but the panel doesn't open reliably in test environment
skip "Calendar panel JavaScript interaction needs debugging"
skip 'Calendar panel JavaScript interaction needs debugging'
end
xit 'persists panel state in localStorage' do