mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Merge pull request #1763 from Freika/feature/precalculating-month-stats-data
Precalculate hexagons for stats
This commit is contained in:
commit
482c0928fe
54 changed files with 1961 additions and 1340 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -12,6 +12,7 @@ This file contains essential information for Claude to work effectively with the
|
|||
- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos)
|
||||
- Export to GeoJSON and GPX formats
|
||||
- Statistics and analytics (countries visited, distance traveled, etc.)
|
||||
- Public sharing of monthly statistics with time-based expiration
|
||||
- Trips management with photo integration
|
||||
- Areas and visits tracking
|
||||
- Integration with photo management systems (Immich, Photoprism)
|
||||
|
|
@ -75,7 +76,7 @@ This file contains essential information for Claude to work effectively with the
|
|||
- **Trip**: User-defined travel periods with analytics
|
||||
- **Import**: Data import operations
|
||||
- **Export**: Data export operations
|
||||
- **Stat**: Calculated statistics and metrics
|
||||
- **Stat**: Calculated statistics and metrics with public sharing capabilities
|
||||
|
||||
### Geographic Features
|
||||
- Uses PostGIS for advanced geographic queries
|
||||
|
|
@ -126,11 +127,41 @@ npx playwright test # E2E tests
|
|||
- Various import jobs for different data sources
|
||||
- Statistical calculation jobs
|
||||
|
||||
## Public Sharing System
|
||||
|
||||
### Overview
|
||||
Dawarich includes a comprehensive public sharing system that allows users to share their monthly statistics with others without requiring authentication. This feature enables users to showcase their location data while maintaining privacy control through configurable expiration settings.
|
||||
|
||||
### Key Features
|
||||
- **Time-based expiration**: Share links can expire after 1 hour, 12 hours, 24 hours, or be permanent
|
||||
- **UUID-based access**: Each shared stat has a unique, unguessable UUID for security
|
||||
- **Public API endpoints**: Hexagon map data can be accessed via API without authentication when sharing is enabled
|
||||
- **Automatic cleanup**: Expired shares are automatically inaccessible
|
||||
- **Privacy controls**: Users can enable/disable sharing and regenerate sharing URLs at any time
|
||||
|
||||
### Technical Implementation
|
||||
- **Database**: `sharing_settings` (JSONB) and `sharing_uuid` (UUID) columns on `stats` table
|
||||
- **Routes**: `/shared/stats/:uuid` for public viewing, `/stats/:year/:month/sharing` for management
|
||||
- **API**: `/api/v1/maps/hexagons` supports public access via `uuid` parameter
|
||||
- **Controllers**: `Shared::StatsController` handles public views, sharing management integrated into existing stats flow
|
||||
|
||||
### Security Features
|
||||
- **No authentication bypass**: Public sharing only exposes specifically designed endpoints
|
||||
- **UUID-based access**: Sharing URLs use unguessable UUIDs rather than sequential IDs
|
||||
- **Expiration enforcement**: Automatic expiration checking prevents access to expired shares
|
||||
- **Limited data exposure**: Only monthly statistics and hexagon data are publicly accessible
|
||||
|
||||
### Usage Patterns
|
||||
- **Social sharing**: Users can share interesting travel months with friends and family
|
||||
- **Portfolio/showcase**: Travel bloggers and photographers can showcase location statistics
|
||||
- **Data collaboration**: Researchers can share aggregated location data for analysis
|
||||
- **Public demonstrations**: Demo instances can provide public examples without compromising user data
|
||||
|
||||
## API Documentation
|
||||
|
||||
- **Framework**: rSwag (Swagger/OpenAPI)
|
||||
- **Location**: `/api-docs` endpoint
|
||||
- **Authentication**: API key (Bearer) for API access
|
||||
- **Authentication**: API key (Bearer) for API access, UUID-based access for public shares
|
||||
|
||||
## Database Schema
|
||||
|
||||
|
|
@ -142,7 +173,7 @@ npx playwright test # E2E tests
|
|||
- `visits` - Detected area visits
|
||||
- `trips` - Travel periods
|
||||
- `imports`/`exports` - Data transfer operations
|
||||
- `stats` - Calculated metrics
|
||||
- `stats` - Calculated metrics with sharing capabilities (`sharing_settings`, `sharing_uuid`)
|
||||
|
||||
### PostGIS Integration
|
||||
- Extensive use of PostGIS geometry types
|
||||
|
|
@ -201,6 +232,11 @@ bundle exec bundle-audit # Dependency security
|
|||
4. **Testing**: Include both unit and integration tests for location-based features
|
||||
5. **Performance**: Consider database indexes for geographic queries
|
||||
6. **Security**: Never log or expose user location data inappropriately
|
||||
7. **Public Sharing**: When implementing features that interact with stats, consider public sharing access patterns:
|
||||
- Use `public_accessible?` method to check if a stat can be publicly accessed
|
||||
- Support UUID-based access in API endpoints when appropriate
|
||||
- Respect expiration settings and disable sharing when expired
|
||||
- Only expose minimal necessary data in public sharing contexts
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
1
Gemfile
1
Gemfile
|
|
@ -17,6 +17,7 @@ gem 'devise'
|
|||
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
||||
gem 'gpx'
|
||||
gem 'groupdate'
|
||||
gem 'h3', '~> 3.7'
|
||||
gem 'httparty'
|
||||
gem 'importmap-rails'
|
||||
gem 'jwt', '~> 2.8'
|
||||
|
|
|
|||
11
Gemfile.lock
11
Gemfile.lock
|
|
@ -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
46
app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
Executable file
46
app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
|
||||
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||
</g>
|
||||
<g id="_Group_" data-name="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
|
||||
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_Group_4" data-name="<Group>">
|
||||
<g>
|
||||
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
|
||||
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
|
||||
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
|
||||
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
|
||||
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
1
app/assets/svg/icons/lucide/outline/goal.svg
Normal file
1
app/assets/svg/icons/lucide/outline/goal.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-goal-icon lucide-goal"><path d="M12 13V2l8 4-8 4"/><path d="M20.561 10.222a9 9 0 1 1-12.55-5.29"/><path d="M8.002 9.997a5 5 0 1 0 8.9 2.02"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
|
|
@ -2,124 +2,85 @@
|
|||
|
||||
class Api::V1::Maps::HexagonsController < ApiController
|
||||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||
before_action :validate_bbox_params, except: [:bounds]
|
||||
before_action :set_user_and_dates
|
||||
|
||||
def index
|
||||
service = Maps::HexagonGrid.new(hexagon_params)
|
||||
result = service.call
|
||||
context = resolve_hexagon_context
|
||||
|
||||
result = Maps::HexagonRequestHandler.new(
|
||||
params: params,
|
||||
user: context[:user] || current_api_user,
|
||||
stat: context[:stat],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
).call
|
||||
|
||||
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
||||
render json: result
|
||||
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||
Maps::HexagonGrid::InvalidCoordinatesError => e
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: "Missing required parameter: #{e.param}" }, status: :bad_request
|
||||
rescue ActionController::BadRequest => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'Shared stats not found or no longer available' }, status: :not_found
|
||||
rescue Stats::CalculateMonth::PostGISError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue Maps::HexagonGrid::PostGISError => e
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
rescue StandardError => _e
|
||||
handle_service_error
|
||||
end
|
||||
|
||||
def bounds
|
||||
# Get the bounding box of user's points for the date range
|
||||
return render json: { error: 'No user found' }, status: :not_found unless @target_user
|
||||
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
||||
context = resolve_hexagon_context
|
||||
|
||||
# Convert dates to timestamps (handle both string and timestamp formats)
|
||||
start_timestamp = case @start_date
|
||||
when String
|
||||
# Check if it's a numeric string (timestamp) or date string
|
||||
if @start_date.match?(/^\d+$/)
|
||||
@start_date.to_i
|
||||
else
|
||||
Time.parse(@start_date).to_i
|
||||
end
|
||||
when Integer
|
||||
@start_date
|
||||
else
|
||||
@start_date.to_i
|
||||
end
|
||||
end_timestamp = case @end_date
|
||||
when String
|
||||
# Check if it's a numeric string (timestamp) or date string
|
||||
if @end_date.match?(/^\d+$/)
|
||||
@end_date.to_i
|
||||
else
|
||||
Time.parse(@end_date).to_i
|
||||
end
|
||||
when Integer
|
||||
@end_date
|
||||
else
|
||||
@end_date.to_i
|
||||
end
|
||||
result = Maps::BoundsCalculator.new(
|
||||
user: context[:user] || context[:target_user],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
).call
|
||||
|
||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||
point_count = points_relation.count
|
||||
|
||||
if point_count.positive?
|
||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3",
|
||||
'bounds_query',
|
||||
[@target_user.id, start_timestamp, end_timestamp]
|
||||
).first
|
||||
|
||||
render json: {
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f,
|
||||
point_count: point_count
|
||||
}
|
||||
if result[:success]
|
||||
render json: result[:data]
|
||||
else
|
||||
render json: {
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
error: result[:error],
|
||||
point_count: result[:point_count]
|
||||
}, status: :not_found
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'Shared stats not found or no longer available' }, status: :not_found
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue Maps::BoundsCalculator::NoUserFoundError => e
|
||||
render json: { error: e.message }, status: :not_found
|
||||
rescue Maps::BoundsCalculator::NoDateRangeError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bbox_params
|
||||
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||
def resolve_hexagon_context
|
||||
return resolve_public_sharing_context if public_sharing_request?
|
||||
|
||||
resolve_authenticated_context
|
||||
end
|
||||
|
||||
def hexagon_params
|
||||
bbox_params.merge(
|
||||
user_id: @target_user&.id,
|
||||
start_date: @start_date,
|
||||
end_date: @end_date
|
||||
)
|
||||
def resolve_public_sharing_context
|
||||
stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
raise ActiveRecord::RecordNotFound unless stat&.public_accessible?
|
||||
|
||||
{
|
||||
user: stat.user,
|
||||
start_date: Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601,
|
||||
end_date: Date.new(stat.year, stat.month, 1).end_of_month.end_of_day.iso8601,
|
||||
stat: stat
|
||||
}
|
||||
end
|
||||
|
||||
def set_user_and_dates
|
||||
return set_public_sharing_context if params[:uuid].present?
|
||||
|
||||
set_authenticated_context
|
||||
end
|
||||
|
||||
def set_public_sharing_context
|
||||
@stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
|
||||
unless @stat&.public_accessible?
|
||||
render json: {
|
||||
error: 'Shared stats not found or no longer available'
|
||||
}, status: :not_found and return
|
||||
end
|
||||
|
||||
@target_user = @stat.user
|
||||
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day.iso8601
|
||||
@end_date = Date.new(@stat.year, @stat.month, 1).end_of_month.end_of_day.iso8601
|
||||
end
|
||||
|
||||
def set_authenticated_context
|
||||
@target_user = current_api_user
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
def resolve_authenticated_context
|
||||
{
|
||||
user: current_api_user,
|
||||
start_date: params[:start_date],
|
||||
end_date: params[:end_date],
|
||||
stat: nil
|
||||
}
|
||||
end
|
||||
|
||||
def handle_service_error
|
||||
|
|
@ -129,15 +90,4 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
def public_sharing_request?
|
||||
params[:uuid].present?
|
||||
end
|
||||
|
||||
def validate_bbox_params
|
||||
required_params = %w[min_lon min_lat max_lon max_lat]
|
||||
missing_params = required_params.select { |param| params[param].blank? }
|
||||
|
||||
return unless missing_params.any?
|
||||
|
||||
render json: {
|
||||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||
}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UserHelper
|
||||
def api_key_qr_code(user)
|
||||
def api_key_qr_code(user, size: 6)
|
||||
json = { 'server_url' => root_url, 'api_key' => user.api_key }
|
||||
qrcode = RQRCode::QRCode.new(json.to_json)
|
||||
svg = qrcode.as_svg(
|
||||
color: '000',
|
||||
fill: 'fff',
|
||||
shape_rendering: 'crispEdges',
|
||||
module_size: 6,
|
||||
module_size: size,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
offset: 5
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
13
app/jobs/points/nightly_reverse_geocoding_job.rb
Normal file
13
app/jobs/points/nightly_reverse_geocoding_job.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Points::NightlyReverseGeocodingJob < ApplicationJob
|
||||
queue_as :reverse_geocoding
|
||||
|
||||
def perform
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point|
|
||||
point.async_reverse_geocode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -38,7 +38,7 @@ class Stat < ApplicationRecord
|
|||
|
||||
def sharing_expired?
|
||||
expiration = sharing_settings['expiration']
|
||||
return false if expiration.blank? || expiration == 'permanent'
|
||||
return false if expiration.blank?
|
||||
|
||||
expires_at_value = sharing_settings['expires_at']
|
||||
return true if expires_at_value.blank?
|
||||
|
|
@ -56,11 +56,20 @@ class Stat < ApplicationRecord
|
|||
sharing_enabled? && !sharing_expired?
|
||||
end
|
||||
|
||||
def hexagons_available?
|
||||
h3_hex_ids.present? &&
|
||||
(h3_hex_ids.is_a?(Hash) || h3_hex_ids.is_a?(Array)) &&
|
||||
h3_hex_ids.any?
|
||||
end
|
||||
|
||||
def generate_new_sharing_uuid!
|
||||
update!(sharing_uuid: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def enable_sharing!(expiration: '1h')
|
||||
# Default to 24h if an invalid expiration is provided
|
||||
expiration = '24h' unless %w[1h 12h 24h].include?(expiration)
|
||||
|
||||
expires_at = case expiration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
|
|
@ -71,7 +80,7 @@ class Stat < ApplicationRecord
|
|||
sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => expiration,
|
||||
'expires_at' => expires_at&.iso8601
|
||||
'expires_at' => expires_at.iso8601
|
||||
},
|
||||
sharing_uuid: sharing_uuid || SecureRandom.uuid
|
||||
)
|
||||
|
|
@ -116,6 +125,10 @@ class Stat < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
def process!
|
||||
Stats::CalculatingJob.perform_later(user.id, year, month)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_sharing_uuid
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HexagonQuery
|
||||
# Maximum number of hexagons to return in a single request
|
||||
MAX_HEXAGONS_PER_REQUEST = 5000
|
||||
|
||||
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
|
||||
|
||||
def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil)
|
||||
@min_lon = min_lon
|
||||
@min_lat = min_lat
|
||||
@max_lon = max_lon
|
||||
@max_lat = max_lat
|
||||
@hex_size = hex_size
|
||||
@user_id = user_id
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
binds = []
|
||||
user_sql = build_user_filter(binds)
|
||||
date_filter = build_date_filter(binds)
|
||||
|
||||
sql = build_hexagon_sql(user_sql, date_filter)
|
||||
|
||||
ActiveRecord::Base.connection.exec_query(sql, 'hexagon_sql', binds)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_hexagon_sql(user_sql, date_filter)
|
||||
<<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT
|
||||
ST_Transform(geom, 3857) as geom_utm,
|
||||
geom as geom_wgs84
|
||||
FROM bbox_geom
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
id,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE #{user_sql}
|
||||
#{date_filter}
|
||||
AND ST_Intersects(
|
||||
lonlat,
|
||||
(SELECT geom FROM bbox_geom)::geometry
|
||||
)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid($5, bbox_utm.geom_utm)).j as hex_j
|
||||
FROM bbox_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hex_geom_utm,
|
||||
hex_i,
|
||||
hex_j
|
||||
FROM hex_grid hg
|
||||
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_stats AS (
|
||||
SELECT
|
||||
hwp.hex_geom_utm,
|
||||
hwp.hex_i,
|
||||
hwp.hex_j,
|
||||
COUNT(up.id) as point_count,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
|
||||
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
|
||||
)
|
||||
SELECT
|
||||
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||
hex_i,
|
||||
hex_j,
|
||||
point_count,
|
||||
earliest_point,
|
||||
latest_point,
|
||||
row_number() OVER (ORDER BY point_count DESC) as id
|
||||
FROM hexagon_stats
|
||||
ORDER BY point_count DESC
|
||||
LIMIT $6;
|
||||
SQL
|
||||
end
|
||||
|
||||
def build_user_filter(binds)
|
||||
# Add bbox coordinates: min_lon, min_lat, max_lon, max_lat
|
||||
binds << min_lon
|
||||
binds << min_lat
|
||||
binds << max_lon
|
||||
binds << max_lat
|
||||
|
||||
# Add hex_size
|
||||
binds << hex_size
|
||||
|
||||
# Add limit
|
||||
binds << MAX_HEXAGONS_PER_REQUEST
|
||||
|
||||
if user_id
|
||||
binds << user_id
|
||||
'user_id = $7'
|
||||
else
|
||||
'1=1'
|
||||
end
|
||||
end
|
||||
|
||||
def build_date_filter(binds)
|
||||
return '' unless start_date || end_date
|
||||
|
||||
conditions = []
|
||||
current_param_index = user_id ? 8 : 7 # Account for bbox, hex_size, limit, and potential user_id
|
||||
|
||||
if start_date
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
binds << start_timestamp
|
||||
conditions << "timestamp >= $#{current_param_index}"
|
||||
current_param_index += 1
|
||||
end
|
||||
|
||||
if end_date
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
binds << end_timestamp
|
||||
conditions << "timestamp <= $#{current_param_index}"
|
||||
end
|
||||
|
||||
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
|
||||
end
|
||||
|
||||
def parse_date_to_timestamp(date_string)
|
||||
# Convert ISO date string to timestamp integer
|
||||
Time.parse(date_string).to_i
|
||||
rescue ArgumentError => e
|
||||
ExceptionReporter.call(e, "Invalid date format: #{date_string}")
|
||||
raise ArgumentError, "Invalid date format: #{date_string}"
|
||||
end
|
||||
end
|
||||
94
app/services/maps/bounds_calculator.rb
Normal file
94
app/services/maps/bounds_calculator.rb
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class BoundsCalculator
|
||||
class NoUserFoundError < StandardError; end
|
||||
class NoDateRangeError < StandardError; end
|
||||
|
||||
def initialize(user:, start_date:, end_date:)
|
||||
@user = user
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
validate_inputs!
|
||||
|
||||
start_timestamp = parse_date_parameter(@start_date)
|
||||
end_timestamp = parse_date_parameter(@end_date)
|
||||
|
||||
point_count =
|
||||
@user
|
||||
.points
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:id)
|
||||
.count
|
||||
|
||||
return build_no_data_response if point_count.zero?
|
||||
|
||||
bounds_result = execute_bounds_query(start_timestamp, end_timestamp)
|
||||
build_success_response(bounds_result, point_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_inputs!
|
||||
raise NoUserFoundError, 'No user found' unless @user
|
||||
raise NoDateRangeError, 'No date range specified' unless @start_date && @end_date
|
||||
end
|
||||
|
||||
def execute_bounds_query(start_timestamp, end_timestamp)
|
||||
ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3",
|
||||
'bounds_query',
|
||||
[@user.id, start_timestamp, end_timestamp]
|
||||
).first
|
||||
end
|
||||
|
||||
def build_success_response(bounds_result, point_count)
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f,
|
||||
point_count: point_count
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_no_data_response
|
||||
{
|
||||
success: false,
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
}
|
||||
end
|
||||
|
||||
def parse_date_parameter(param)
|
||||
case param
|
||||
when String
|
||||
if param.match?(/^\d+$/)
|
||||
param.to_i
|
||||
else
|
||||
parsed_time = Time.zone.parse(param)
|
||||
raise ArgumentError, "Invalid date format: #{param}" if parsed_time.nil?
|
||||
|
||||
parsed_time.to_i
|
||||
end
|
||||
when Integer
|
||||
param
|
||||
else
|
||||
param.to_i
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
|
||||
raise ArgumentError, "Invalid date format: #{param}"
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/services/maps/date_parameter_coercer.rb
Normal file
39
app/services/maps/date_parameter_coercer.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class DateParameterCoercer
|
||||
class InvalidDateFormatError < StandardError; end
|
||||
|
||||
def initialize(param)
|
||||
@param = param
|
||||
end
|
||||
|
||||
def call
|
||||
coerce_date(@param)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :param
|
||||
|
||||
def coerce_date(param)
|
||||
case param
|
||||
when String
|
||||
coerce_string_param(param)
|
||||
when Integer
|
||||
param
|
||||
else
|
||||
param.to_i
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
|
||||
raise InvalidDateFormatError, "Invalid date format: #{param}"
|
||||
end
|
||||
|
||||
def coerce_string_param(param)
|
||||
return param.to_i if param.match?(/^\d+$/)
|
||||
|
||||
Time.parse(param).to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
89
app/services/maps/hexagon_center_manager.rb
Normal file
89
app/services/maps/hexagon_center_manager.rb
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class HexagonCenterManager
|
||||
def initialize(stat:, user:)
|
||||
@stat = stat
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
return build_response_from_centers if pre_calculated_centers_available?
|
||||
|
||||
nil # No pre-calculated data available
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :stat, :user
|
||||
|
||||
def pre_calculated_centers_available?
|
||||
return false if stat&.h3_hex_ids.blank?
|
||||
|
||||
stat.h3_hex_ids.is_a?(Array) && stat.h3_hex_ids.any?
|
||||
end
|
||||
|
||||
def build_response_from_centers
|
||||
hex_ids = stat.h3_hex_ids
|
||||
Rails.logger.debug "Using pre-calculated H3 hex IDs: #{hex_ids.size} hexagons"
|
||||
|
||||
result = build_hexagons_from_h3_ids(hex_ids)
|
||||
{ success: true, data: result, pre_calculated: true }
|
||||
end
|
||||
|
||||
def recalculate_h3_hex_ids
|
||||
service = Stats::CalculateMonth.new(user.id, stat.year, stat.month)
|
||||
service.send(:calculate_h3_hex_ids)
|
||||
end
|
||||
|
||||
def update_stat_with_new_hex_ids(new_hex_ids)
|
||||
stat.update(h3_hex_ids: new_hex_ids)
|
||||
result = build_hexagons_from_h3_ids(new_hex_ids)
|
||||
Rails.logger.debug "Successfully recalculated H3 hex IDs: #{new_hex_ids.size} hexagons"
|
||||
{ success: true, data: result, pre_calculated: true }
|
||||
end
|
||||
|
||||
def build_hexagons_from_h3_ids(hex_ids)
|
||||
# Convert stored H3 IDs back to hexagon polygons
|
||||
# Array format: [[h3_index, point_count, earliest, latest], ...]
|
||||
hexagon_features = hex_ids.map.with_index do |row, index|
|
||||
h3_index, count, earliest, latest = row
|
||||
build_hexagon_feature_from_h3(h3_index, [count, earliest, latest], index)
|
||||
end
|
||||
|
||||
build_feature_collection(hexagon_features)
|
||||
end
|
||||
|
||||
def build_hexagon_feature_from_h3(h3_index, data, index)
|
||||
count, earliest, latest = data
|
||||
|
||||
{
|
||||
'type' => 'Feature',
|
||||
'id' => index + 1,
|
||||
'geometry' => Maps::HexagonPolygonGenerator.new(h3_index:).call,
|
||||
'properties' => build_hexagon_properties(index, count, earliest, latest)
|
||||
}
|
||||
end
|
||||
|
||||
def build_hexagon_properties(index, count, earliest, latest)
|
||||
{
|
||||
'hex_id' => index + 1,
|
||||
'point_count' => count,
|
||||
'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil,
|
||||
'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil
|
||||
}
|
||||
end
|
||||
|
||||
def build_feature_collection(hexagon_features)
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => hexagon_features,
|
||||
'metadata' => {
|
||||
'count' => hexagon_features.count,
|
||||
'user_id' => user.id,
|
||||
'pre_calculated' => true
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Maps::HexagonGrid
|
||||
include ActiveModel::Validations
|
||||
|
||||
# Constants for configuration
|
||||
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
|
||||
MAX_AREA_KM2 = 250_000 # 500km x 500km
|
||||
|
||||
# Validation error classes
|
||||
class BoundingBoxTooLargeError < StandardError; end
|
||||
class InvalidCoordinatesError < StandardError; end
|
||||
class PostGISError < StandardError; end
|
||||
|
||||
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date, :viewport_width,
|
||||
:viewport_height
|
||||
|
||||
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
|
||||
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
|
||||
validates :hex_size, numericality: { greater_than: 0 }
|
||||
|
||||
validate :validate_bbox_order
|
||||
validate :validate_area_size
|
||||
|
||||
def initialize(params = {})
|
||||
@min_lon = params[:min_lon].to_f
|
||||
@min_lat = params[:min_lat].to_f
|
||||
@max_lon = params[:max_lon].to_f
|
||||
@max_lat = params[:max_lat].to_f
|
||||
@hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
|
||||
@viewport_width = params[:viewport_width]&.to_f
|
||||
@viewport_height = params[:viewport_height]&.to_f
|
||||
@user_id = params[:user_id]
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
end
|
||||
|
||||
def call
|
||||
validate!
|
||||
|
||||
generate_hexagons
|
||||
end
|
||||
|
||||
def area_km2
|
||||
@area_km2 ||= calculate_area_km2
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_area_km2
|
||||
width = (max_lon - min_lon).abs
|
||||
height = (max_lat - min_lat).abs
|
||||
|
||||
# Convert degrees to approximate kilometers
|
||||
# 1 degree latitude ≈ 111 km
|
||||
# 1 degree longitude ≈ 111 km * cos(latitude)
|
||||
avg_lat = (min_lat + max_lat) / 2
|
||||
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
|
||||
height_km = height * 111
|
||||
|
||||
width_km * height_km
|
||||
end
|
||||
|
||||
def validate_bbox_order
|
||||
errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
|
||||
errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
|
||||
end
|
||||
|
||||
def validate_area_size
|
||||
return unless area_km2 > MAX_AREA_KM2
|
||||
|
||||
errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
|
||||
end
|
||||
|
||||
def generate_hexagons
|
||||
query = HexagonQuery.new(
|
||||
min_lon:, min_lat:, max_lon:, max_lat:,
|
||||
hex_size:, user_id:, start_date:, end_date:
|
||||
)
|
||||
|
||||
result = query.call
|
||||
|
||||
format_hexagons(result)
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
message = "Failed to generate hexagon grid: #{e.message}"
|
||||
|
||||
ExceptionReporter.call(e, message)
|
||||
raise PostGISError, message
|
||||
end
|
||||
|
||||
def format_hexagons(result)
|
||||
total_points = 0
|
||||
|
||||
hexagons = result.map do |row|
|
||||
point_count = row['point_count'].to_i
|
||||
total_points += point_count
|
||||
|
||||
# Parse timestamps and format dates
|
||||
earliest = row['earliest_point'] ? Time.zone.at(row['earliest_point'].to_f).iso8601 : nil
|
||||
latest = row['latest_point'] ? Time.zone.at(row['latest_point'].to_f).iso8601 : nil
|
||||
|
||||
{
|
||||
type: 'Feature',
|
||||
id: row['id'],
|
||||
geometry: JSON.parse(row['geojson']),
|
||||
properties: {
|
||||
hex_id: row['id'],
|
||||
hex_i: row['hex_i'],
|
||||
hex_j: row['hex_j'],
|
||||
hex_size: hex_size,
|
||||
point_count: point_count,
|
||||
earliest_point: earliest,
|
||||
latest_point: latest
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => hexagons,
|
||||
'metadata' => {
|
||||
'bbox' => [min_lon, min_lat, max_lon, max_lat],
|
||||
'area_km2' => area_km2.round(2),
|
||||
'hex_size_m' => hex_size,
|
||||
'count' => hexagons.count,
|
||||
'total_points' => total_points,
|
||||
'user_id' => user_id,
|
||||
'date_range' => build_date_range_metadata
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_date_range_metadata
|
||||
return nil unless start_date || end_date
|
||||
|
||||
{ 'start_date' => start_date, 'end_date' => end_date }
|
||||
end
|
||||
|
||||
def validate!
|
||||
return if valid?
|
||||
|
||||
raise BoundingBoxTooLargeError, errors.full_messages.join(', ') if area_km2 > MAX_AREA_KM2
|
||||
|
||||
raise InvalidCoordinatesError, errors.full_messages.join(', ')
|
||||
end
|
||||
|
||||
def viewport_valid?
|
||||
viewport_width &&
|
||||
viewport_height &&
|
||||
viewport_width.positive? &&
|
||||
viewport_height.positive?
|
||||
end
|
||||
end
|
||||
32
app/services/maps/hexagon_polygon_generator.rb
Normal file
32
app/services/maps/hexagon_polygon_generator.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class HexagonPolygonGenerator
|
||||
def initialize(h3_index:)
|
||||
@h3_index = h3_index
|
||||
end
|
||||
|
||||
def call
|
||||
# Parse H3 index from hex string if needed
|
||||
index = h3_index.is_a?(String) ? h3_index.to_i(16) : h3_index
|
||||
|
||||
# Get the boundary coordinates for this H3 hexagon
|
||||
boundary_coordinates = H3.to_boundary(index)
|
||||
|
||||
# Convert to GeoJSON polygon format (lng, lat)
|
||||
polygon_coordinates = boundary_coordinates.map { [_2, _1] }
|
||||
|
||||
# Close the polygon by adding the first point at the end
|
||||
polygon_coordinates << polygon_coordinates.first
|
||||
|
||||
{
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [polygon_coordinates]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :h3_index
|
||||
end
|
||||
end
|
||||
62
app/services/maps/hexagon_request_handler.rb
Normal file
62
app/services/maps/hexagon_request_handler.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Maps
|
||||
class HexagonRequestHandler
|
||||
def initialize(params:, user: nil, stat: nil, start_date: nil, end_date: nil)
|
||||
@params = params
|
||||
@user = user
|
||||
@stat = stat
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
# For authenticated users, we need to find the matching stat
|
||||
stat ||= find_matching_stat
|
||||
|
||||
if stat
|
||||
cached_result = Maps::HexagonCenterManager.new(stat:, user:).call
|
||||
|
||||
return cached_result[:data] if cached_result&.dig(:success)
|
||||
end
|
||||
|
||||
# No pre-calculated data available - return empty feature collection
|
||||
Rails.logger.debug 'No pre-calculated hexagon centers available'
|
||||
empty_feature_collection
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :params, :user, :stat, :start_date, :end_date
|
||||
|
||||
def find_matching_stat
|
||||
return unless user && start_date
|
||||
|
||||
# Parse the date to extract year and month
|
||||
if start_date.is_a?(String)
|
||||
date = Date.parse(start_date)
|
||||
elsif start_date.is_a?(Time)
|
||||
date = start_date.to_date
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
# Find the stat for this user, year, and month
|
||||
user.stats.find_by(year: date.year, month: date.month)
|
||||
rescue Date::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def empty_feature_collection
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => [],
|
||||
'metadata' => {
|
||||
'hexagon_count' => 0,
|
||||
'total_points' => 0,
|
||||
'source' => 'pre_calculated'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,6 +17,8 @@ class OwnTracks::Importer
|
|||
parsed_data = OwnTracks::RecParser.new(file_content).call
|
||||
|
||||
points_data = parsed_data.map do |point|
|
||||
next unless point_valid?(point)
|
||||
|
||||
OwnTracks::Params.new(point).call.merge(
|
||||
import_id: import.id,
|
||||
user_id: user_id,
|
||||
|
|
@ -31,7 +33,7 @@ class OwnTracks::Importer
|
|||
private
|
||||
|
||||
def bulk_insert_points(batch)
|
||||
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
unique_batch = batch.compact.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Point.upsert_all(
|
||||
|
|
@ -42,6 +44,8 @@ class OwnTracks::Importer
|
|||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to bulk insert OwnTracks points for user #{user_id}: #{e.message}")
|
||||
|
||||
create_notification("Failed to process OwnTracks data: #{e.message}")
|
||||
end
|
||||
|
||||
|
|
@ -53,4 +57,10 @@ class OwnTracks::Importer
|
|||
kind: :error
|
||||
)
|
||||
end
|
||||
|
||||
def point_valid?(point)
|
||||
point['lat'].present? &&
|
||||
point['lon'].present? &&
|
||||
point['tst'].present?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ class Stats::CalculateMonth
|
|||
stat.assign_attributes(
|
||||
daily_distance: distance_by_day,
|
||||
distance: distance(distance_by_day),
|
||||
toponyms: toponyms
|
||||
toponyms: toponyms,
|
||||
h3_hex_ids: calculate_h3_hex_ids
|
||||
)
|
||||
stat.save
|
||||
end
|
||||
|
|
@ -82,4 +83,8 @@ class Stats::CalculateMonth
|
|||
def destroy_month_stats(year, month)
|
||||
Stat.where(year:, month:, user:).destroy_all
|
||||
end
|
||||
|
||||
def calculate_h3_hex_ids
|
||||
Stats::HexagonCalculator.new(user.id, year, month).call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
120
app/services/stats/hexagon_calculator.rb
Normal file
120
app/services/stats/hexagon_calculator.rb
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Stats::HexagonCalculator
|
||||
# H3 Configuration
|
||||
DEFAULT_H3_RESOLUTION = 8 # Small hexagons for good detail
|
||||
MAX_HEXAGONS = 10_000 # Maximum number of hexagons to prevent memory issues
|
||||
|
||||
class PostGISError < StandardError; end
|
||||
|
||||
def initialize(user_id, year, month)
|
||||
@user = User.find(user_id)
|
||||
@year = year.to_i
|
||||
@month = month.to_i
|
||||
end
|
||||
|
||||
def call(h3_resolution: DEFAULT_H3_RESOLUTION)
|
||||
calculate_h3_hexagon_centers(h3_resolution)
|
||||
end
|
||||
|
||||
def calculate_h3_hex_ids
|
||||
result = calculate_hexagons(DEFAULT_H3_RESOLUTION)
|
||||
return {} if result.nil?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :year, :month
|
||||
|
||||
def calculate_h3_hexagon_centers(h3_resolution)
|
||||
result = calculate_hexagons(h3_resolution)
|
||||
return [] if result.nil?
|
||||
|
||||
# Convert to array format: [h3_index_string, point_count, earliest_timestamp, latest_timestamp]
|
||||
result.map do |h3_index_string, data|
|
||||
[
|
||||
h3_index_string,
|
||||
data[0], # count
|
||||
data[1], # earliest
|
||||
data[2] # latest
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
# Unified hexagon calculation method
|
||||
def calculate_hexagons(h3_resolution)
|
||||
return nil if points.empty?
|
||||
|
||||
begin
|
||||
h3_hash = calculate_h3_indexes(points, h3_resolution)
|
||||
|
||||
if h3_hash.empty?
|
||||
Rails.logger.info "No H3 hex IDs calculated for user #{user.id}, #{year}-#{month} (no data)"
|
||||
return nil
|
||||
end
|
||||
|
||||
if h3_hash.size > MAX_HEXAGONS
|
||||
Rails.logger.warn "Too many hexagons (#{h3_hash.size}), using lower resolution"
|
||||
# Try with lower resolution (larger hexagons)
|
||||
lower_resolution = [h3_resolution - 2, 0].max
|
||||
Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
|
||||
# Create a new instance with lower resolution for recursion
|
||||
return self.class.new(user.id, year, month).calculate_hexagons(lower_resolution)
|
||||
end
|
||||
|
||||
Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}"
|
||||
h3_hash
|
||||
rescue StandardError => e
|
||||
message = "Failed to calculate H3 hexagon centers: #{e.message}"
|
||||
ExceptionReporter.call(e, message) if defined?(ExceptionReporter)
|
||||
raise PostGISError, message
|
||||
end
|
||||
end
|
||||
|
||||
def start_timestamp
|
||||
DateTime.new(year, month, 1).to_i
|
||||
end
|
||||
|
||||
def end_timestamp
|
||||
DateTime.new(year, month, -1).to_i # -1 returns last day of month
|
||||
end
|
||||
|
||||
def points
|
||||
return @points if defined?(@points)
|
||||
|
||||
@points = user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.where.not(lonlat: nil)
|
||||
.select(:lonlat, :timestamp)
|
||||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def calculate_h3_indexes(points, h3_resolution)
|
||||
h3_data = {}
|
||||
|
||||
points.find_each do |point|
|
||||
# Extract lat/lng from PostGIS point
|
||||
coordinates = [point.lonlat.y, point.lonlat.x] # [lat, lng] for H3
|
||||
|
||||
# Get H3 index for this point
|
||||
h3_index = H3.from_geo_coordinates(coordinates, h3_resolution.clamp(0, 15))
|
||||
h3_index_string = h3_index.to_s(16) # Convert to hex string immediately
|
||||
|
||||
# Initialize or update data for this hexagon
|
||||
if h3_data[h3_index_string]
|
||||
data = h3_data[h3_index_string]
|
||||
data[0] += 1 # increment count
|
||||
data[1] = [data[1], point.timestamp].min # update earliest
|
||||
data[2] = [data[2], point.timestamp].max # update latest
|
||||
else
|
||||
h3_data[h3_index_string] = [1, point.timestamp, point.timestamp] # [count, earliest, latest]
|
||||
end
|
||||
end
|
||||
|
||||
h3_data
|
||||
end
|
||||
end
|
||||
|
|
@ -1,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 %>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@
|
|||
<%= options_for_select([
|
||||
['1 hour', '1h'],
|
||||
['12 hours', '12h'],
|
||||
['24 hours', '24h'],
|
||||
['Permanent', 'permanent']
|
||||
['24 hours', '24h']
|
||||
], @stat&.sharing_settings&.dig('expiration') || '1h') %>
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb
Normal file
12
db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb
Normal 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
4
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -222,7 +222,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
|
|||
t.jsonb "daily_distance", default: {}
|
||||
t.jsonb "sharing_settings", default: {}
|
||||
t.uuid "sharing_uuid"
|
||||
t.jsonb "h3_hex_ids", default: {}
|
||||
t.index ["distance"], name: "index_stats_on_distance"
|
||||
t.index ["h3_hex_ids"], name: "index_stats_on_h3_hex_ids", where: "((h3_hex_ids IS NOT NULL) AND (h3_hex_ids <> '{}'::jsonb))", using: :gin
|
||||
t.index ["month"], name: "index_stats_on_month"
|
||||
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
|
||||
t.index ["user_id"], name: "index_stats_on_user_id"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ FactoryBot.define do
|
|||
|
||||
trait :with_sharing_enabled do
|
||||
after(:create) do |stat, _evaluator|
|
||||
stat.enable_sharing!(expiration: 'permanent')
|
||||
stat.enable_sharing!(expiration: '24h')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
FactoryBot.define do
|
||||
factory :user do
|
||||
sequence :email do |n|
|
||||
"user#{n}@example.com"
|
||||
"user#{n}-#{Time.current.to_f}@example.com"
|
||||
end
|
||||
|
||||
status { :active }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
|
|||
let(:area) { create(:area, user: user) }
|
||||
|
||||
it 'calls the AreaVisitsCalculationService' do
|
||||
allow(User).to receive(:find_each).and_yield(user)
|
||||
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
|
||||
|
||||
described_class.new.perform
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
|
|||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
|
@ -69,8 +67,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
|
|||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
|||
]
|
||||
allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(chunks)
|
||||
|
||||
active_users_mock = double('ActiveRecord::Relation')
|
||||
allow(User).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:find_each).and_yield(user_with_points)
|
||||
|
||||
chunks.each do |chunk|
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
|
|
@ -94,6 +100,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
|||
.and_return(time_chunks_instance)
|
||||
allow(time_chunks_instance).to receive(:call).and_return(custom_chunks)
|
||||
|
||||
active_users_mock = double('ActiveRecord::Relation')
|
||||
allow(User).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:active).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock)
|
||||
allow(active_users_mock).to receive(:find_each).and_yield(user_with_points)
|
||||
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
start_at: custom_chunks.first.first,
|
||||
|
|
|
|||
125
spec/jobs/points/nightly_reverse_geocoding_job_spec.rb
Normal file
125
spec/jobs/points/nightly_reverse_geocoding_job_spec.rb
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
# Clear any existing jobs and points to ensure test isolation
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
Point.delete_all
|
||||
end
|
||||
|
||||
context 'when reverse geocoding is disabled' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
let!(:point_without_geocoding) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
|
||||
it 'does not process any points' do
|
||||
expect_any_instance_of(Point).not_to receive(:async_reverse_geocode)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'returns early without querying points' do
|
||||
allow(Point).to receive(:not_reverse_geocoded)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(Point).not_to have_received(:not_reverse_geocoded)
|
||||
end
|
||||
|
||||
it 'does not enqueue any ReverseGeocodingJob jobs' do
|
||||
expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reverse geocoding is enabled' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
context 'with no points needing reverse geocoding' do
|
||||
let!(:geocoded_point) do
|
||||
create(:point, user: user, reverse_geocoded_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'does not process any points' do
|
||||
expect_any_instance_of(Point).not_to receive(:async_reverse_geocode)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'does not enqueue any ReverseGeocodingJob jobs' do
|
||||
expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points needing reverse geocoding' do
|
||||
let!(:point_without_geocoding1) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
let!(:point_without_geocoding2) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
let!(:geocoded_point) do
|
||||
create(:point, user: user, reverse_geocoded_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'processes all points that need reverse geocoding' do
|
||||
expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times
|
||||
end
|
||||
|
||||
it 'enqueues jobs with correct parameters' do
|
||||
expect { described_class.perform_now }
|
||||
.to have_enqueued_job(ReverseGeocodingJob)
|
||||
.with('Point', point_without_geocoding1.id)
|
||||
.and have_enqueued_job(ReverseGeocodingJob)
|
||||
.with('Point', point_without_geocoding2.id)
|
||||
end
|
||||
|
||||
it 'uses find_each with correct batch size' do
|
||||
relation_mock = double('ActiveRecord::Relation')
|
||||
allow(Point).to receive(:not_reverse_geocoded).and_return(relation_mock)
|
||||
allow(relation_mock).to receive(:find_each).with(batch_size: 1000)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(relation_mock).to have_received(:find_each).with(batch_size: 1000)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'queue configuration' do
|
||||
it 'uses the reverse_geocoding queue' do
|
||||
expect(described_class.queue_name).to eq('reverse_geocoding')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error handling' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
let!(:point_without_geocoding) do
|
||||
create(:point, user: user, reverse_geocoded_at: nil)
|
||||
end
|
||||
|
||||
context 'when a point fails to reverse geocode' do
|
||||
before do
|
||||
allow_any_instance_of(Point).to receive(:async_reverse_geocode).and_raise(StandardError, 'API error')
|
||||
end
|
||||
|
||||
it 'continues processing other points despite individual failures' do
|
||||
expect { described_class.perform_now }.to raise_error(StandardError, 'API error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -26,6 +26,11 @@ RSpec.describe Tracks::DailyGenerationJob, type: :job do
|
|||
active_user.update!(points_count: active_user.points.count)
|
||||
trial_user.update!(points_count: trial_user.points.count)
|
||||
|
||||
# Mock User.active_or_trial to only return test users
|
||||
active_or_trial_mock = double('ActiveRecord::Relation')
|
||||
allow(User).to receive(:active_or_trial).and_return(active_or_trial_mock)
|
||||
allow(active_or_trial_mock).to receive(:find_each).and_yield(active_user).and_yield(trial_user)
|
||||
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UsersMailer, type: :mailer do
|
||||
let(:user) { create(:user, email: 'test@example.com') }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app'))
|
||||
|
|
@ -14,11 +14,11 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('Welcome to Dawarich!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
|
||||
it 'renders the body' do
|
||||
expect(mail.body.encoded).to match('test@example.com')
|
||||
expect(mail.body.encoded).to match(user.email)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('Explore Dawarich features!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('💔 Your Dawarich trial expired')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.to).to eq([user.email])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -53,11 +53,17 @@ RSpec.describe Point, type: :model do
|
|||
end
|
||||
|
||||
describe '.not_reverse_geocoded' do
|
||||
let(:point) { create(:point, country: 'Country', city: 'City') }
|
||||
let(:point_without_address) { create(:point, city: nil, country: nil) }
|
||||
let!(:point) { create(:point, country: 'Country', city: 'City', reverse_geocoded_at: Time.current) }
|
||||
let!(:point_without_address) { create(:point, city: nil, country: nil, reverse_geocoded_at: nil) }
|
||||
|
||||
it 'returns points without reverse geocoded address' do
|
||||
expect(described_class.not_reverse_geocoded).to eq([point_without_address])
|
||||
# Trigger creation of both points
|
||||
point
|
||||
point_without_address
|
||||
|
||||
result = described_class.not_reverse_geocoded
|
||||
expect(result).to include(point_without_address)
|
||||
expect(result).not_to include(point)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe HexagonQuery, type: :query do
|
||||
let(:user) { create(:user) }
|
||||
let(:min_lon) { -74.1 }
|
||||
let(:min_lat) { 40.6 }
|
||||
let(:max_lon) { -73.9 }
|
||||
let(:max_lat) { 40.8 }
|
||||
let(:hex_size) { 500 }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets required parameters' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size
|
||||
)
|
||||
|
||||
expect(query.min_lon).to eq(min_lon)
|
||||
expect(query.min_lat).to eq(min_lat)
|
||||
expect(query.max_lon).to eq(max_lon)
|
||||
expect(query.max_lat).to eq(max_lat)
|
||||
expect(query.hex_size).to eq(hex_size)
|
||||
end
|
||||
|
||||
it 'sets optional parameters' do
|
||||
start_date = '2024-06-01T00:00:00Z'
|
||||
end_date = '2024-06-30T23:59:59Z'
|
||||
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
|
||||
expect(query.user_id).to eq(user.id)
|
||||
expect(query.start_date).to eq(start_date)
|
||||
expect(query.end_date).to eq(end_date)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:query) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
context 'with no points' do
|
||||
it 'executes without error and returns empty result' do
|
||||
result = query.call
|
||||
expect(result.to_a).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points in bounding box' do
|
||||
before do
|
||||
# Create test points within the bounding box
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.75,
|
||||
longitude: -73.95,
|
||||
timestamp: Time.new(2024, 6, 16, 14, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns hexagon results with expected structure' do
|
||||
result = query.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
first_hex = result_array.first
|
||||
expect(first_hex).to have_key('geojson')
|
||||
expect(first_hex).to have_key('hex_i')
|
||||
expect(first_hex).to have_key('hex_j')
|
||||
expect(first_hex).to have_key('point_count')
|
||||
expect(first_hex).to have_key('earliest_point')
|
||||
expect(first_hex).to have_key('latest_point')
|
||||
expect(first_hex).to have_key('id')
|
||||
|
||||
# Verify geojson can be parsed
|
||||
geojson = JSON.parse(first_hex['geojson'])
|
||||
expect(geojson).to have_key('type')
|
||||
expect(geojson).to have_key('coordinates')
|
||||
end
|
||||
|
||||
it 'filters by user_id correctly' do
|
||||
other_user = create(:user)
|
||||
# Create points for a different user (should be excluded)
|
||||
create(:point,
|
||||
user: other_user,
|
||||
latitude: 40.72,
|
||||
longitude: -73.98,
|
||||
timestamp: Time.new(2024, 6, 17, 16, 0).to_i)
|
||||
|
||||
result = query.call
|
||||
result_array = result.to_a
|
||||
|
||||
# Should only include hexagons with the specified user's points
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(2) # Only the 2 points from our user
|
||||
end
|
||||
end
|
||||
|
||||
context 'with date filtering' do
|
||||
let(:query_with_dates) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-15T00:00:00Z',
|
||||
end_date: '2024-06-16T23:59:59Z'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create points within and outside the date range
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i) # Within range
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.71,
|
||||
longitude: -74.01,
|
||||
timestamp: Time.new(2024, 6, 20, 12, 0).to_i) # Outside range
|
||||
end
|
||||
|
||||
it 'filters points by date range' do
|
||||
result = query_with_dates.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
# Should only include the point within the date range
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without user_id filter' do
|
||||
let(:query_no_user) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
user1 = create(:user)
|
||||
user2 = create(:user)
|
||||
|
||||
create(:point, user: user1, latitude: 40.7, longitude: -74.0, timestamp: Time.current.to_i)
|
||||
create(:point, user: user2, latitude: 40.75, longitude: -73.95, timestamp: Time.current.to_i)
|
||||
end
|
||||
|
||||
it 'includes points from all users' do
|
||||
result = query_no_user.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
# Should include points from both users
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_date_filter (private method behavior)' do
|
||||
context 'when testing date filter behavior through query execution' do
|
||||
it 'works correctly with start_date only' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-15T00:00:00Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'works correctly with end_date only' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'works correctly with both start_date and end_date' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,7 +17,6 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
min_lat: 40.6,
|
||||
max_lon: -73.9,
|
||||
max_lat: 40.8,
|
||||
hex_size: 1000,
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
}
|
||||
|
|
@ -49,32 +48,45 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
expect(json_response['features']).to be_an(Array)
|
||||
end
|
||||
|
||||
it 'requires all bbox parameters' do
|
||||
incomplete_params = valid_params.except(:min_lon)
|
||||
context 'with no data points' do
|
||||
let(:empty_user) { create(:user) }
|
||||
let(:empty_headers) { { 'Authorization' => "Bearer #{empty_user.api_key}" } }
|
||||
|
||||
get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
expect(json_response['error']).to include('min_lon')
|
||||
end
|
||||
|
||||
it 'handles service validation errors' do
|
||||
invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
|
||||
|
||||
get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'uses custom hex_size when provided' do
|
||||
custom_params = valid_params.merge(hex_size: 500)
|
||||
|
||||
get '/api/v1/maps/hexagons', params: custom_params, headers: headers
|
||||
it 'returns empty feature collection' do
|
||||
get '/api/v1/maps/hexagons', params: valid_params, headers: empty_headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['type']).to eq('FeatureCollection')
|
||||
expect(json_response['features']).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with edge case coordinates' do
|
||||
it 'handles coordinates at dateline' do
|
||||
dateline_params = valid_params.merge(
|
||||
min_lon: 179.0, max_lon: -179.0,
|
||||
min_lat: -1.0, max_lat: 1.0
|
||||
)
|
||||
|
||||
get '/api/v1/maps/hexagons', params: dateline_params, headers: headers
|
||||
|
||||
# Should either succeed or return appropriate error, not crash
|
||||
expect([200, 400, 500]).to include(response.status)
|
||||
end
|
||||
|
||||
it 'handles polar coordinates' do
|
||||
polar_params = valid_params.merge(
|
||||
min_lon: -180.0, max_lon: 180.0,
|
||||
min_lat: 85.0, max_lat: 90.0
|
||||
)
|
||||
|
||||
get '/api/v1/maps/hexagons', params: polar_params, headers: headers
|
||||
|
||||
# Should either succeed or return appropriate error, not crash
|
||||
expect([200, 400, 500]).to include(response.status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -157,6 +169,87 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
expect(json_response['error']).to eq('Shared stats not found or no longer available')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with pre-calculated hexagon centers' do
|
||||
let(:pre_calculated_centers) do
|
||||
{
|
||||
'8a1fb46622dffff' => [5, 1_717_200_000, 1_717_203_600], # count, earliest, latest timestamps
|
||||
'8a1fb46622e7fff' => [3, 1_717_210_000, 1_717_213_600],
|
||||
'8a1fb46632dffff' => [8, 1_717_220_000, 1_717_223_600]
|
||||
}
|
||||
end
|
||||
let(:stat) do
|
||||
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6, h3_hex_ids: pre_calculated_centers)
|
||||
end
|
||||
|
||||
it 'uses pre-calculated hexagon centers instead of on-the-fly calculation' do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['type']).to eq('FeatureCollection')
|
||||
expect(json_response['features'].length).to eq(3)
|
||||
expect(json_response['metadata']['pre_calculated']).to be true
|
||||
expect(json_response['metadata']['count']).to eq(3)
|
||||
|
||||
# Verify hexagon properties are generated correctly
|
||||
feature = json_response['features'].first
|
||||
expect(feature['type']).to eq('Feature')
|
||||
expect(feature['geometry']['type']).to eq('Polygon')
|
||||
expect(feature['geometry']['coordinates'].first).to be_an(Array)
|
||||
expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing vertex
|
||||
|
||||
# Verify properties include timestamp data
|
||||
expect(feature['properties']['earliest_point']).to be_present
|
||||
expect(feature['properties']['latest_point']).to be_present
|
||||
end
|
||||
|
||||
it 'generates proper hexagon polygons from centers' do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
feature = json_response['features'].first
|
||||
coordinates = feature['geometry']['coordinates'].first
|
||||
|
||||
# Verify hexagon has 6 unique vertices plus closing vertex
|
||||
expect(coordinates.length).to eq(7)
|
||||
expect(coordinates.first).to eq(coordinates.last) # Closed polygon
|
||||
expect(coordinates.uniq.length).to eq(6) # 6 unique vertices
|
||||
|
||||
# Verify all vertices are different (not collapsed to a point)
|
||||
coordinates[0..5].each_with_index do |vertex, i|
|
||||
next_vertex = coordinates[(i + 1) % 6]
|
||||
expect(vertex).not_to eq(next_vertex)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with legacy area_too_large hexagon data' do
|
||||
let(:stat) do
|
||||
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
|
||||
h3_hex_ids: { 'area_too_large' => true })
|
||||
end
|
||||
|
||||
before do
|
||||
# Create points so that the service can potentially succeed
|
||||
5.times do |i|
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.001),
|
||||
longitude: -74.0 + (i * 0.001),
|
||||
timestamp: Time.new(2024, 6, 15, 12, i).to_i)
|
||||
end
|
||||
end
|
||||
|
||||
it 'handles legacy area_too_large flag gracefully' do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
# The endpoint should handle the legacy data gracefully and not crash
|
||||
# We're primarily testing that the condition `@stat&.h3_hex_ids&.dig('area_too_large')` is covered
|
||||
expect([200, 400, 500]).to include(response.status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'without authentication' do
|
||||
|
|
@ -220,6 +313,59 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
|||
expect(json_response['error']).to eq('No data found for the specified date range')
|
||||
expect(json_response['point_count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'requires date range parameters' do
|
||||
get '/api/v1/maps/hexagons/bounds', headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('No date range specified')
|
||||
end
|
||||
|
||||
it 'handles different timestamp formats' do
|
||||
string_date_params = {
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
}
|
||||
|
||||
get '/api/v1/maps/hexagons/bounds', params: string_date_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
|
||||
end
|
||||
|
||||
it 'handles numeric string timestamp format' do
|
||||
numeric_string_params = {
|
||||
start_date: '1717200000', # June 1, 2024 in timestamp
|
||||
end_date: '1719791999' # June 30, 2024 in timestamp
|
||||
}
|
||||
|
||||
get '/api/v1/maps/hexagons/bounds', params: numeric_string_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
|
||||
end
|
||||
|
||||
context 'error handling' do
|
||||
it 'handles invalid date format gracefully' do
|
||||
invalid_date_params = {
|
||||
start_date: 'invalid-date',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
}
|
||||
|
||||
get '/api/v1/maps/hexagons/bounds', params: invalid_date_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Invalid date format')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with public sharing UUID' do
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ RSpec.describe 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)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe Areas::Visits::Create do
|
||||
describe '#call' do
|
||||
let(:user) { create(:user) }
|
||||
let!(:user) { create(:user) }
|
||||
let(:home_area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100) }
|
||||
let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) }
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do
|
|||
it 'creates points with correct data' do
|
||||
parser
|
||||
|
||||
expect(Point.all[6].lat).to eq(27.696576)
|
||||
expect(Point.all[6].lon).to eq(-97.376949)
|
||||
expect(Point.all[6].timestamp).to eq(1_693_180_140)
|
||||
expect(user.points[6].lat).to eq(27.696576)
|
||||
expect(user.points[6].lon).to eq(-97.376949)
|
||||
expect(user.points[6].timestamp).to eq(1_693_180_140)
|
||||
|
||||
expect(Point.last.lat).to eq(27.709617)
|
||||
expect(Point.last.lon).to eq(-97.375988)
|
||||
expect(Point.last.timestamp).to eq(1_693_180_320)
|
||||
expect(user.points.last.lat).to eq(27.709617)
|
||||
expect(user.points.last.lon).to eq(-97.375988)
|
||||
expect(user.points.last.timestamp).to eq(1_693_180_320)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,11 +57,13 @@ RSpec.describe Gpx::TrackImporter do
|
|||
it 'creates points with correct data' do
|
||||
parser
|
||||
|
||||
expect(Point.first.lat).to eq(37.1722103)
|
||||
expect(Point.first.lon).to eq(-3.55468)
|
||||
expect(Point.first.altitude).to eq(1066)
|
||||
expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i)
|
||||
expect(Point.first.velocity).to eq('2.9')
|
||||
point = user.points.first
|
||||
|
||||
expect(point.lat).to eq(37.1722103)
|
||||
expect(point.lon).to eq(-3.55468)
|
||||
expect(point.altitude).to eq(1066)
|
||||
expect(point.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i)
|
||||
expect(point.velocity).to eq('2.9')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -71,11 +73,13 @@ RSpec.describe Gpx::TrackImporter do
|
|||
it 'creates points with correct data' do
|
||||
parser
|
||||
|
||||
expect(Point.first.lat).to eq(10.758321212464024)
|
||||
expect(Point.first.lon).to eq(106.64234449272531)
|
||||
expect(Point.first.altitude).to eq(17)
|
||||
expect(Point.first.timestamp).to eq(1_730_626_211)
|
||||
expect(Point.first.velocity).to eq('2.8')
|
||||
point = user.points.first
|
||||
|
||||
expect(point.lat).to eq(10.758321212464024)
|
||||
expect(point.lon).to eq(106.64234449272531)
|
||||
expect(point.altitude).to eq(17)
|
||||
expect(point.timestamp).to eq(1_730_626_211)
|
||||
expect(point.velocity).to eq('2.8')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
117
spec/services/maps/bounds_calculator_spec.rb
Normal file
117
spec/services/maps/bounds_calculator_spec.rb
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Maps::BoundsCalculator do
|
||||
describe '.call' do
|
||||
subject(:calculate_bounds) do
|
||||
described_class.new(user:, start_date:, end_date:).call
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:start_date) { '2024-06-01T00:00:00Z' }
|
||||
let(:end_date) { '2024-06-30T23:59:59Z' }
|
||||
|
||||
context 'with valid user and date range' do
|
||||
before do
|
||||
# Create test points within the date range
|
||||
create(:point, user:, latitude: 40.6, longitude: -74.1,
|
||||
timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
|
||||
create(:point, user:, latitude: 40.8, longitude: -73.9,
|
||||
timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
|
||||
create(:point, user:, latitude: 40.7, longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns success with bounds data' do
|
||||
expect(calculate_bounds).to match(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
min_lat: 40.6,
|
||||
max_lat: 40.8,
|
||||
min_lng: -74.1,
|
||||
max_lng: -73.9,
|
||||
point_count: 3
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no points in date range' do
|
||||
before do
|
||||
# Create points outside the date range
|
||||
create(:point, user:, latitude: 40.7, longitude: -74.0,
|
||||
timestamp: Time.new(2024, 5, 15, 10, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns failure with no data message' do
|
||||
expect(calculate_bounds).to match(
|
||||
{
|
||||
success: false,
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no user' do
|
||||
let(:user) { nil }
|
||||
|
||||
it 'raises NoUserFoundError' do
|
||||
expect { calculate_bounds }.to raise_error(
|
||||
Maps::BoundsCalculator::NoUserFoundError,
|
||||
'No user found'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no start date' do
|
||||
let(:start_date) { nil }
|
||||
|
||||
it 'raises NoDateRangeError' do
|
||||
expect { calculate_bounds }.to raise_error(
|
||||
Maps::BoundsCalculator::NoDateRangeError,
|
||||
'No date range specified'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no end date' do
|
||||
let(:end_date) { nil }
|
||||
|
||||
it 'raises NoDateRangeError' do
|
||||
expect { calculate_bounds }.to raise_error(
|
||||
Maps::BoundsCalculator::NoDateRangeError,
|
||||
'No date range specified'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid date parsing' do
|
||||
let(:start_date) { 'invalid-date' }
|
||||
|
||||
it 'raises ArgumentError for invalid dates' do
|
||||
expect { calculate_bounds }.to raise_error(ArgumentError, 'Invalid date format: invalid-date')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with timestamp format dates' do
|
||||
let(:start_date) { 1_717_200_000 }
|
||||
let(:end_date) { 1_719_791_999 }
|
||||
|
||||
before do
|
||||
create(:point, user:, latitude: 41.0, longitude: -74.5,
|
||||
timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
|
||||
end
|
||||
|
||||
it 'handles timestamp format correctly' do
|
||||
result = calculate_bounds
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:data][:point_count]).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
70
spec/services/maps/date_parameter_coercer_spec.rb
Normal file
70
spec/services/maps/date_parameter_coercer_spec.rb
Normal 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
|
||||
77
spec/services/maps/hexagon_center_manager_spec.rb
Normal file
77
spec/services/maps/hexagon_center_manager_spec.rb
Normal 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
|
||||
90
spec/services/maps/hexagon_polygon_generator_spec.rb
Normal file
90
spec/services/maps/hexagon_polygon_generator_spec.rb
Normal 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
|
||||
137
spec/services/maps/hexagon_request_handler_spec.rb
Normal file
137
spec/services/maps/hexagon_request_handler_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
123
spec/services/stats/hexagon_calculator_spec.rb
Normal file
123
spec/services/stats/hexagon_calculator_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue