Update onborading popup

This commit is contained in:
Eugene Burmakin 2025-09-18 18:29:46 +02:00
parent c67532bb10
commit 5b3fe84933
33 changed files with 635 additions and 479 deletions

View file

@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Fixed ## Fixed
- Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745 - 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.
# [0.32.0] - 2025-09-13 # [0.32.0] - 2025-09-13

View file

@ -12,6 +12,7 @@ This file contains essential information for Claude to work effectively with the
- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos) - Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos)
- Export to GeoJSON and GPX formats - Export to GeoJSON and GPX formats
- Statistics and analytics (countries visited, distance traveled, etc.) - Statistics and analytics (countries visited, distance traveled, etc.)
- Public sharing of monthly statistics with time-based expiration
- Trips management with photo integration - Trips management with photo integration
- Areas and visits tracking - Areas and visits tracking
- Integration with photo management systems (Immich, Photoprism) - 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 - **Trip**: User-defined travel periods with analytics
- **Import**: Data import operations - **Import**: Data import operations
- **Export**: Data export operations - **Export**: Data export operations
- **Stat**: Calculated statistics and metrics - **Stat**: Calculated statistics and metrics with public sharing capabilities
### Geographic Features ### Geographic Features
- Uses PostGIS for advanced geographic queries - Uses PostGIS for advanced geographic queries
@ -126,11 +127,41 @@ npx playwright test # E2E tests
- Various import jobs for different data sources - Various import jobs for different data sources
- Statistical calculation jobs - 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 ## API Documentation
- **Framework**: rSwag (Swagger/OpenAPI) - **Framework**: rSwag (Swagger/OpenAPI)
- **Location**: `/api-docs` endpoint - **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 ## Database Schema
@ -142,7 +173,7 @@ npx playwright test # E2E tests
- `visits` - Detected area visits - `visits` - Detected area visits
- `trips` - Travel periods - `trips` - Travel periods
- `imports`/`exports` - Data transfer operations - `imports`/`exports` - Data transfer operations
- `stats` - Calculated metrics - `stats` - Calculated metrics with sharing capabilities (`sharing_settings`, `sharing_uuid`)
### PostGIS Integration ### PostGIS Integration
- Extensive use of PostGIS geometry types - 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 4. **Testing**: Include both unit and integration tests for location-based features
5. **Performance**: Consider database indexes for geographic queries 5. **Performance**: Consider database indexes for geographic queries
6. **Security**: Never log or expose user location data inappropriately 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 ## Contributing

View file

@ -0,0 +1,46 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-goal-icon lucide-goal"><path d="M12 13V2l8 4-8 4"/><path d="M20.561 10.222a9 9 0 1 1-12.55-5.29"/><path d="M8.002 9.997a5 5 0 1 0 8.9 2.02"/></svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -4,7 +4,9 @@ class Api::V1::Maps::HexagonsController < ApiController
skip_before_action :authenticate_api_key, if: :public_sharing_request? skip_before_action :authenticate_api_key, if: :public_sharing_request?
def index def index
result = Maps::H3HexagonRenderer.call( return unless public_sharing_request? || validate_required_parameters
result = Maps::HexagonRequestHandler.call(
params: params, params: params,
current_api_user: current_api_user current_api_user: current_api_user
) )
@ -28,11 +30,11 @@ class Api::V1::Maps::HexagonsController < ApiController
current_api_user: current_api_user current_api_user: current_api_user
) )
result = Maps::BoundsCalculator.call( result = Maps::BoundsCalculator.new(
target_user: context[:target_user], target_user: context[:target_user],
start_date: context[:start_date], start_date: context[:start_date],
end_date: context[:end_date] end_date: context[:end_date]
) ).call
if result[:success] if result[:success]
render json: result[:data] render json: result[:data]
@ -65,4 +67,39 @@ class Api::V1::Maps::HexagonsController < ApiController
def public_sharing_request? def public_sharing_request?
params[:uuid].present? params[:uuid].present?
end end
def validate_required_parameters
required_params = %i[min_lon max_lon min_lat max_lat start_date end_date]
missing_params = required_params.select { |param| params[param].blank? }
unless missing_params.empty?
error_message = "Missing required parameters: #{missing_params.join(', ')}"
render json: { error: error_message }, status: :bad_request
return false
end
# Validate coordinate ranges
if !valid_coordinate_ranges?
render json: { error: 'Invalid coordinate ranges' }, status: :bad_request
return false
end
true
end
def valid_coordinate_ranges?
min_lon = params[:min_lon].to_f
max_lon = params[:max_lon].to_f
min_lat = params[:min_lat].to_f
max_lat = params[:max_lat].to_f
# Check longitude range (-180 to 180)
return false unless (-180..180).cover?(min_lon) && (-180..180).cover?(max_lon)
# Check latitude range (-90 to 90)
return false unless (-90..90).cover?(min_lat) && (-90..90).cover?(max_lat)
# Check that min values are less than max values
return false unless min_lon < max_lon && min_lat < max_lat
true
end
end end

View file

@ -1,14 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
module UserHelper 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 } json = { 'server_url' => root_url, 'api_key' => user.api_key }
qrcode = RQRCode::QRCode.new(json.to_json) qrcode = RQRCode::QRCode.new(json.to_json)
svg = qrcode.as_svg( svg = qrcode.as_svg(
color: '000', color: '000',
fill: 'fff', fill: 'fff',
shape_rendering: 'crispEdges', shape_rendering: 'crispEdges',
module_size: 6, module_size: size,
standalone: true, standalone: true,
use_path: true, use_path: true,
offset: 5 offset: 5

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Points::NightlyReverseGeocodingJob < ApplicationJob
queue_as :reverse_geocoding
def perform
return unless DawarichSettings.reverse_geocoding_enabled?
Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point|
point.async_reverse_geocode
end
end
end

View file

@ -6,10 +6,6 @@ module Maps
class NoDateRangeError < StandardError; end class NoDateRangeError < StandardError; end
class NoDataFoundError < StandardError; end class NoDataFoundError < StandardError; end
def self.call(target_user:, start_date:, end_date:)
new(target_user: target_user, start_date: start_date, end_date: end_date).call
end
def initialize(target_user:, start_date:, end_date:) def initialize(target_user:, start_date:, end_date:)
@target_user = target_user @target_user = target_user
@start_date = start_date @start_date = start_date
@ -19,8 +15,8 @@ module Maps
def call def call
validate_inputs! validate_inputs!
start_timestamp = Maps::DateParameterCoercer.call(@start_date) start_timestamp = Maps::DateParameterCoercer.new(@start_date).call
end_timestamp = Maps::DateParameterCoercer.call(@end_date) end_timestamp = Maps::DateParameterCoercer.new(@end_date).call
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp) points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
point_count = points_relation.count point_count = points_relation.count

View file

@ -4,10 +4,6 @@ module Maps
class DateParameterCoercer class DateParameterCoercer
class InvalidDateFormatError < StandardError; end class InvalidDateFormatError < StandardError; end
def self.call(param)
new(param).call
end
def initialize(param) def initialize(param)
@param = param @param = param
end end

View file

@ -2,7 +2,7 @@
module Maps module Maps
class H3HexagonCalculator class H3HexagonCalculator
def initialize(user_id, start_date, end_date, h3_resolution = 5) def initialize(user_id, start_date, end_date, h3_resolution = 8)
@user_id = user_id @user_id = user_id
@start_date = start_date @start_date = start_date
@end_date = end_date @end_date = end_date
@ -32,7 +32,8 @@ module Maps
attr_reader :user_id, :start_date, :end_date, :h3_resolution attr_reader :user_id, :start_date, :end_date, :h3_resolution
def fetch_user_points def fetch_user_points
Point.where(user_id: user_id) Point.without_raw_data
.where(user_id: user_id)
.where(timestamp: start_date.to_i..end_date.to_i) .where(timestamp: start_date.to_i..end_date.to_i)
.where.not(lonlat: nil) .where.not(lonlat: nil)
.select(:id, :lonlat, :timestamp) .select(:id, :lonlat, :timestamp)
@ -81,4 +82,4 @@ module Maps
end end
end end
end end
end end

View file

@ -34,7 +34,7 @@ class Maps::H3HexagonCenters
if h3_indexes_with_counts.size > MAX_HEXAGONS if h3_indexes_with_counts.size > MAX_HEXAGONS
Rails.logger.warn "Too many hexagons (#{h3_indexes_with_counts.size}), using lower resolution" Rails.logger.warn "Too many hexagons (#{h3_indexes_with_counts.size}), using lower resolution"
# Try with lower resolution (larger hexagons) # Try with lower resolution (larger hexagons)
return recalculate_with_lower_resolution(points) return recalculate_with_lower_resolution
end end
Rails.logger.info "Generated #{h3_indexes_with_counts.size} H3 hexagons at resolution #{h3_resolution} for user #{user_id}" Rails.logger.info "Generated #{h3_indexes_with_counts.size} H3 hexagons at resolution #{h3_resolution} for user #{user_id}"
@ -50,20 +50,23 @@ class Maps::H3HexagonCenters
end end
rescue StandardError => e rescue StandardError => e
message = "Failed to calculate H3 hexagon centers: #{e.message}" message = "Failed to calculate H3 hexagon centers: #{e.message}"
ExceptionReporter.call(e, message) if defined?(ExceptionReporter) ExceptionReporter.call(e, message)
raise PostGISError, message raise PostGISError, message
end end
private private
def fetch_user_points def fetch_user_points
start_timestamp = parse_date_to_timestamp(start_date) start_timestamp = Maps::DateParameterCoercer.new(start_date).call
end_timestamp = parse_date_to_timestamp(end_date) end_timestamp = Maps::DateParameterCoercer.new(end_date).call
Point.where(user_id: user_id) Point.where(user_id: user_id)
.where(timestamp: start_timestamp..end_timestamp) .where(timestamp: start_timestamp..end_timestamp)
.where.not(lonlat: nil) .where.not(lonlat: nil)
.select(:id, :lonlat, :timestamp) .select(:id, :lonlat, :timestamp)
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
ExceptionReporter.call(e, e.message) if defined?(ExceptionReporter)
raise ArgumentError, e.message
end end
def calculate_h3_indexes(points) def calculate_h3_indexes(points)
@ -86,7 +89,7 @@ class Maps::H3HexagonCenters
h3_data h3_data
end end
def recalculate_with_lower_resolution(points) def recalculate_with_lower_resolution
# Try with resolution 2 levels lower (4x larger hexagons) # Try with resolution 2 levels lower (4x larger hexagons)
lower_resolution = [h3_resolution - 2, 0].max lower_resolution = [h3_resolution - 2, 0].max
@ -102,27 +105,9 @@ class Maps::H3HexagonCenters
service.call service.call
end end
def parse_date_to_timestamp(date)
case date
when String
if date.match?(/^\d+$/)
date.to_i
else
Time.parse(date).to_i
end
when Integer
date
else
Time.parse(date.to_s).to_i
end
rescue ArgumentError => e
ExceptionReporter.call(e, "Invalid date format: #{date}") if defined?(ExceptionReporter)
raise ArgumentError, "Invalid date format: #{date}"
end
def validate! def validate!
return if valid? return if valid?
raise InvalidCoordinatesError, errors.full_messages.join(', ') raise InvalidCoordinatesError, errors.full_messages.join(', ')
end end
end end

View file

@ -2,10 +2,6 @@
module Maps module Maps
class H3HexagonRenderer class H3HexagonRenderer
def self.call(params:, current_api_user: nil)
new(params: params, current_api_user: current_api_user).call
end
def initialize(params:, current_api_user: nil) def initialize(params:, current_api_user: nil)
@params = params @params = params
@current_api_user = current_api_user @current_api_user = current_api_user
@ -47,7 +43,7 @@ module Maps
end end
# For authenticated users, calculate on-the-fly if no pre-calculated data # For authenticated users, calculate on-the-fly if no pre-calculated data
Rails.logger.debug "No pre-calculated H3 data, calculating on-the-fly" Rails.logger.debug 'No pre-calculated H3 data, calculating on-the-fly'
generate_h3_data_on_the_fly(context) generate_h3_data_on_the_fly(context)
end end
@ -56,14 +52,12 @@ module Maps
end_date = parse_date_for_h3(context[:end_date]) end_date = parse_date_for_h3(context[:end_date])
h3_resolution = params[:h3_resolution]&.to_i&.clamp(0, 15) || 6 h3_resolution = params[:h3_resolution]&.to_i&.clamp(0, 15) || 6
service = Maps::H3HexagonCenters.new( Maps::H3HexagonCenters.new(
user_id: context[:target_user]&.id, user_id: context[:target_user]&.id,
start_date: start_date, start_date: start_date,
end_date: end_date, end_date: end_date,
h3_resolution: h3_resolution h3_resolution: h3_resolution
) ).call
service.call
end end
def convert_h3_to_geojson(h3_data) def convert_h3_to_geojson(h3_data)
@ -124,14 +118,14 @@ module Maps
return date_param if date_param.is_a?(Time) return date_param if date_param.is_a?(Time)
# If it's a string ISO date, parse it directly to Time # If it's a string ISO date, parse it directly to Time
return Time.parse(date_param) if date_param.is_a?(String) return Time.zone.parse(date_param) if date_param.is_a?(String)
# If it's an integer timestamp, convert to Time # If it's an integer timestamp, convert to Time
return Time.at(date_param) if date_param.is_a?(Integer) return Time.zone.at(date_param) if date_param.is_a?(Integer)
# For other cases, try coercing and converting # For other cases, try coercing and converting
timestamp = Maps::DateParameterCoercer.call(date_param) timestamp = Maps::DateParameterCoercer.new(date_param).call
Time.at(timestamp) Time.zone.at(timestamp)
end end
end end
end end

View file

@ -14,19 +14,22 @@ module Maps
def call def call
context = resolve_context context = resolve_context
# Try to use pre-calculated hexagon centers first # For authenticated users, we need to find the matching stat
if context[:stat] stat = context[:stat] || find_matching_stat(context)
# Use pre-calculated hexagon centers
if stat
cached_result = Maps::HexagonCenterManager.call( cached_result = Maps::HexagonCenterManager.call(
stat: context[:stat], stat: stat,
target_user: context[:target_user] target_user: context[:target_user]
) )
return cached_result[:data] if cached_result&.dig(:success) return cached_result[:data] if cached_result&.dig(:success)
end end
# Fall back to on-the-fly calculation # No pre-calculated data available - return empty feature collection
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly' Rails.logger.debug 'No pre-calculated hexagon centers available'
generate_hexagons_on_the_fly(context) empty_feature_collection
end end
private private
@ -40,58 +43,35 @@ module Maps
) )
end end
def generate_hexagons_on_the_fly(context)
# Parse dates for H3 calculator which expects Time objects
start_date = parse_date_for_h3(context[:start_date])
end_date = parse_date_for_h3(context[:end_date])
result = Maps::H3HexagonCalculator.new( def find_matching_stat(context)
context[:target_user]&.id, return unless context[:target_user] && context[:start_date]
start_date,
end_date,
h3_resolution
).call
return result[:data] if result[:success] # Parse the date to extract year and month
if context[:start_date].is_a?(String)
date = Date.parse(context[:start_date])
elsif context[:start_date].is_a?(Time)
date = context[:start_date].to_date
else
return
end
# If H3 calculation fails, log error and return empty feature collection # Find the stat for this user, year, and month
Rails.logger.error "H3 calculation failed: #{result[:error]}" context[:target_user].stats.find_by(year: date.year, month: date.month)
empty_feature_collection rescue Date::Error
nil
end end
def empty_feature_collection def empty_feature_collection
{ {
type: 'FeatureCollection', 'type' => 'FeatureCollection',
features: [], 'features' => [],
metadata: { 'metadata' => {
hexagon_count: 0, 'hexagon_count' => 0,
total_points: 0, 'total_points' => 0,
source: 'h3' 'source' => 'pre_calculated'
} }
} }
end end
def h3_resolution
# Allow custom resolution via parameter, default to 8
resolution = params[:h3_resolution]&.to_i || 8
# Clamp to valid H3 resolution range (0-15)
resolution.clamp(0, 15)
end
def parse_date_for_h3(date_param)
# If already a Time object (from public sharing context), return as-is
return date_param if date_param.is_a?(Time)
# If it's a string ISO date, parse it directly to Time
return Time.parse(date_param) if date_param.is_a?(String)
# If it's an integer timestamp, convert to Time
return Time.at(date_param) if date_param.is_a?(Integer)
# For other cases, try coercing and converting
timestamp = Maps::DateParameterCoercer.call(date_param)
Time.at(timestamp)
end
end end
end end

View file

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

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled? if defined?(Rails::Server) && !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled?
require 'prometheus_exporter/middleware' require 'prometheus_exporter/middleware'
require 'prometheus_exporter/instrumentation' require 'prometheus_exporter/instrumentation'

View file

@ -39,3 +39,8 @@ daily_track_generation_job:
cron: "0 */4 * * *" # every 4 hours cron: "0 */4 * * *" # every 4 hours
class: "Tracks::DailyGenerationJob" class: "Tracks::DailyGenerationJob"
queue: tracks queue: tracks
nightly_reverse_geocoding_job:
cron: "15 1 * * *" # every day at 01:15
class: "Points::NightlyReverseGeocodingJob"
queue: tracks

View file

@ -3,7 +3,7 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
sequence :email do |n| sequence :email do |n|
"user#{n}@example.com" "user#{n}-#{Time.current.to_f}@example.com"
end end
status { :active } status { :active }

View file

@ -8,6 +8,7 @@ RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
let(:area) { create(:area, user: user) } let(:area) { create(:area, user: user) }
it 'calls the AreaVisitsCalculationService' do 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 expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
described_class.new.perform described_class.new.perform

View file

@ -23,8 +23,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
end end
before do 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(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call) allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end end
@ -69,8 +67,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
end end
before do 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(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call) allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end end

View file

@ -26,6 +26,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
end end
it 'schedules jobs only for active users with tracked points' do it 'schedules jobs only for active users with tracked points' do
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).and_yield(user)
expect(VisitSuggestingJob).to receive(:perform_later).with( expect(VisitSuggestingJob).to receive(:perform_later).with(
user_id: user_with_points.id, user_id: user_with_points.id,
start_at: time_chunks.first.first, start_at: time_chunks.first.first,
@ -54,6 +60,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
] ]
allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(chunks) 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| chunks.each do |chunk|
expect(VisitSuggestingJob).to receive(:perform_later).with( expect(VisitSuggestingJob).to receive(:perform_later).with(
user_id: user_with_points.id, user_id: user_with_points.id,
@ -94,6 +106,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
.and_return(time_chunks_instance) .and_return(time_chunks_instance)
allow(time_chunks_instance).to receive(:call).and_return(custom_chunks) 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( expect(VisitSuggestingJob).to receive(:perform_later).with(
user_id: user_with_points.id, user_id: user_with_points.id,
start_at: custom_chunks.first.first, start_at: custom_chunks.first.first,

View file

@ -0,0 +1,158 @@
# 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
context 'with large number of points needing reverse geocoding' do
before do
# Create 2500 points to test batching
points_data = (1..2500).map do |i|
{
user_id: user.id,
latitude: 40.7128 + (i * 0.0001),
longitude: -74.0060 + (i * 0.0001),
timestamp: Time.current.to_i + i,
lonlat: "POINT(#{-74.0060 + (i * 0.0001)} #{40.7128 + (i * 0.0001)})",
reverse_geocoded_at: nil,
created_at: Time.current,
updated_at: Time.current
}
end
Point.insert_all(points_data)
end
it 'processes all points in batches' do
expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2500).times
end
it 'uses efficient batching to avoid memory issues' do
relation_mock = double('ActiveRecord::Relation')
allow(Point).to receive(:not_reverse_geocoded).and_return(relation_mock)
allow(relation_mock).to receive(:find_each).with(batch_size: 1000)
described_class.perform_now
expect(relation_mock).to have_received(:find_each).with(batch_size: 1000)
end
end
end
describe 'queue configuration' do
it 'uses the reverse_geocoding queue' do
expect(described_class.queue_name).to eq('reverse_geocoding')
end
end
describe 'error handling' do
before do
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
end
let!(:point_without_geocoding) do
create(:point, user: user, reverse_geocoded_at: nil)
end
context 'when a point fails to reverse geocode' do
before do
allow_any_instance_of(Point).to receive(:async_reverse_geocode).and_raise(StandardError, 'API error')
end
it 'continues processing other points despite individual failures' do
expect { described_class.perform_now }.to raise_error(StandardError, 'API error')
end
end
end
end
end

View file

@ -26,6 +26,11 @@ RSpec.describe Tracks::DailyGenerationJob, type: :job do
active_user.update!(points_count: active_user.points.count) active_user.update!(points_count: active_user.points.count)
trial_user.update!(points_count: trial_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 ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end end

View file

@ -3,7 +3,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe UsersMailer, type: :mailer do RSpec.describe UsersMailer, type: :mailer do
let(:user) { create(:user, email: 'test@example.com') } let(:user) { create(:user) }
before do before do
stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) 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 it 'renders the headers' do
expect(mail.subject).to eq('Welcome to Dawarich!') expect(mail.subject).to eq('Welcome to Dawarich!')
expect(mail.to).to eq(['test@example.com']) expect(mail.to).to eq([user.email])
end end
it 'renders the body' do it 'renders the body' do
expect(mail.body.encoded).to match('test@example.com') expect(mail.body.encoded).to match(user.email)
end end
end end
@ -27,7 +27,7 @@ RSpec.describe UsersMailer, type: :mailer do
it 'renders the headers' do it 'renders the headers' do
expect(mail.subject).to eq('Explore Dawarich features!') 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
end end
@ -36,7 +36,7 @@ RSpec.describe UsersMailer, type: :mailer do
it 'renders the headers' do it 'renders the headers' do
expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days') 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
end end
@ -45,7 +45,7 @@ RSpec.describe UsersMailer, type: :mailer do
it 'renders the headers' do it 'renders the headers' do
expect(mail.subject).to eq('💔 Your Dawarich trial expired') 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
end end
@ -54,7 +54,7 @@ RSpec.describe UsersMailer, type: :mailer do
it 'renders the headers' do it 'renders the headers' do
expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!') 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
end end
@ -63,7 +63,7 @@ RSpec.describe UsersMailer, type: :mailer do
it 'renders the headers' do it 'renders the headers' do
expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich') 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 end
end end

View file

@ -6,7 +6,7 @@ RSpec.describe Api::UserSerializer do
describe '#call' do describe '#call' do
subject(:serializer) { described_class.new(user).call } 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 it 'returns JSON with correct user attributes' do
expect(serializer[:user][:email]).to eq(user.email) expect(serializer[:user][:email]).to eq(user.email)

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Areas::Visits::Create do RSpec.describe Areas::Visits::Create do
describe '#call' 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(:home_area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100) }
let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) } let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) }

View file

@ -39,13 +39,13 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do
it 'creates points with correct data' do it 'creates points with correct data' do
parser parser
expect(Point.all[6].lat).to eq(27.696576) expect(user.points[6].lat).to eq(27.696576)
expect(Point.all[6].lon).to eq(-97.376949) expect(user.points[6].lon).to eq(-97.376949)
expect(Point.all[6].timestamp).to eq(1_693_180_140) expect(user.points[6].timestamp).to eq(1_693_180_140)
expect(Point.last.lat).to eq(27.709617) expect(user.points.last.lat).to eq(27.709617)
expect(Point.last.lon).to eq(-97.375988) expect(user.points.last.lon).to eq(-97.375988)
expect(Point.last.timestamp).to eq(1_693_180_320) expect(user.points.last.timestamp).to eq(1_693_180_320)
end end
end end
end end

View file

@ -57,11 +57,13 @@ RSpec.describe Gpx::TrackImporter do
it 'creates points with correct data' do it 'creates points with correct data' do
parser parser
expect(Point.first.lat).to eq(37.1722103) point = user.points.first
expect(Point.first.lon).to eq(-3.55468)
expect(Point.first.altitude).to eq(1066) expect(point.lat).to eq(37.1722103)
expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i) expect(point.lon).to eq(-3.55468)
expect(Point.first.velocity).to eq('2.9') 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
end end
@ -71,11 +73,13 @@ RSpec.describe Gpx::TrackImporter do
it 'creates points with correct data' do it 'creates points with correct data' do
parser parser
expect(Point.first.lat).to eq(10.758321212464024) point = user.points.first
expect(Point.first.lon).to eq(106.64234449272531)
expect(Point.first.altitude).to eq(17) expect(point.lat).to eq(10.758321212464024)
expect(Point.first.timestamp).to eq(1_730_626_211) expect(point.lon).to eq(106.64234449272531)
expect(Point.first.velocity).to eq('2.8') expect(point.altitude).to eq(17)
expect(point.timestamp).to eq(1_730_626_211)
expect(point.velocity).to eq('2.8')
end end
end end

View file

@ -5,11 +5,11 @@ require 'rails_helper'
RSpec.describe Maps::BoundsCalculator do RSpec.describe Maps::BoundsCalculator do
describe '.call' do describe '.call' do
subject(:calculate_bounds) do subject(:calculate_bounds) do
described_class.call( described_class.new(
target_user: target_user, target_user: target_user,
start_date: start_date, start_date: start_date,
end_date: end_date end_date: end_date
) ).call
end end
let(:user) { create(:user) } let(:user) { create(:user) }
@ -29,16 +29,18 @@ RSpec.describe Maps::BoundsCalculator do
end end
it 'returns success with bounds data' do it 'returns success with bounds data' do
expect(calculate_bounds).to match({ expect(calculate_bounds).to match(
success: true, {
data: { success: true,
min_lat: 40.6, data: {
max_lat: 40.8, min_lat: 40.6,
min_lng: -74.1, max_lat: 40.8,
max_lng: -73.9, min_lng: -74.1,
point_count: 3 max_lng: -73.9,
point_count: 3
}
} }
}) )
end end
end end
@ -50,11 +52,13 @@ RSpec.describe Maps::BoundsCalculator do
end end
it 'returns failure with no data message' do it 'returns failure with no data message' do
expect(calculate_bounds).to match({ expect(calculate_bounds).to match(
success: false, {
error: 'No data found for the specified date range', success: false,
point_count: 0 error: 'No data found for the specified date range',
}) point_count: 0
}
)
end end
end end
@ -117,4 +121,4 @@ RSpec.describe Maps::BoundsCalculator do
end end
end end
end end
end end

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Maps::DateParameterCoercer do RSpec.describe Maps::DateParameterCoercer do
describe '.call' do describe '.call' do
subject(:coerce_date) { described_class.call(param) } subject(:coerce_date) { described_class.new(param).call }
context 'with integer parameter' do context 'with integer parameter' do
let(:param) { 1_717_200_000 } let(:param) { 1_717_200_000 }
@ -67,4 +67,4 @@ RSpec.describe Maps::DateParameterCoercer do
end end
end end
end end
end end

View file

@ -17,39 +17,36 @@ RSpec.describe Maps::HexagonRequestHandler do
before do before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
# Clean up database state to avoid conflicts - order matters due to foreign keys
Point.delete_all
Stat.delete_all
User.delete_all
end end
context 'with authenticated user and bounding box params' do context 'with authenticated user but no pre-calculated data' do
let(:params) do let(:params) do
ActionController::Parameters.new({ ActionController::Parameters.new(
min_lon: -74.1, {
min_lat: 40.6, min_lon: -74.1,
max_lon: -73.9, min_lat: 40.6,
max_lat: 40.8, max_lon: -73.9,
hex_size: 1000, max_lat: 40.8,
start_date: '2024-06-01T00:00:00Z', hex_size: 1000,
end_date: '2024-06-30T23:59:59Z' start_date: '2024-06-01T00:00:00Z',
}) end_date: '2024-06-30T23:59:59Z'
}
)
end end
before do it 'returns empty feature collection when no pre-calculated data' do
# Create test points within the date range and bounding box
10.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 'returns on-the-fly hexagon calculation' do
result = handle_request result = handle_request
expect(result).to be_a(Hash) expect(result).to be_a(Hash)
expect(result['type']).to eq('FeatureCollection') expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array) expect(result['features']).to eq([])
expect(result['metadata']).to be_present expect(result['metadata']['hexagon_count']).to eq(0)
expect(result['metadata']['source']).to eq('pre_calculated')
end end
end end
@ -65,14 +62,16 @@ RSpec.describe Maps::HexagonRequestHandler do
hexagon_centers: pre_calculated_centers) hexagon_centers: pre_calculated_centers)
end end
let(:params) do let(:params) do
ActionController::Parameters.new({ ActionController::Parameters.new(
uuid: stat.sharing_uuid, {
min_lon: -74.1, uuid: stat.sharing_uuid,
min_lat: 40.6, min_lon: -74.1,
max_lon: -73.9, min_lat: 40.6,
max_lat: 40.8, max_lon: -73.9,
hex_size: 1000 max_lat: 40.8,
}) hex_size: 1000
}
)
end end
let(:current_api_user) { nil } let(:current_api_user) { nil }
@ -89,35 +88,26 @@ RSpec.describe Maps::HexagonRequestHandler do
context 'with public sharing UUID but no pre-calculated centers' do context 'with public sharing UUID but no pre-calculated centers' do
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
let(:params) do let(:params) do
ActionController::Parameters.new({ ActionController::Parameters.new(
uuid: stat.sharing_uuid, {
min_lon: -74.1, uuid: stat.sharing_uuid,
min_lat: 40.6, min_lon: -74.1,
max_lon: -73.9, min_lat: 40.6,
max_lat: 40.8, max_lon: -73.9,
hex_size: 1000 max_lat: 40.8,
}) hex_size: 1000
}
)
end end
let(:current_api_user) { nil } let(:current_api_user) { nil }
before do it 'returns empty feature collection when no pre-calculated centers' do
# Create test points for the stat's month
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 'falls back to on-the-fly calculation' do
result = handle_request result = handle_request
expect(result['type']).to eq('FeatureCollection') expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array) expect(result['features']).to eq([])
expect(result['metadata']).to be_present expect(result['metadata']['hexagon_count']).to eq(0)
expect(result['metadata']['pre_calculated']).to be_falsy expect(result['metadata']['source']).to eq('pre_calculated')
end end
end end
@ -127,14 +117,16 @@ RSpec.describe Maps::HexagonRequestHandler do
hexagon_centers: { 'area_too_large' => true }) hexagon_centers: { 'area_too_large' => true })
end end
let(:params) do let(:params) do
ActionController::Parameters.new({ ActionController::Parameters.new(
uuid: stat.sharing_uuid, {
min_lon: -74.1, uuid: stat.sharing_uuid,
min_lat: 40.6, min_lon: -74.1,
max_lon: -73.9, min_lat: 40.6,
max_lat: 40.8, max_lon: -73.9,
hex_size: 1000 max_lat: 40.8,
}) hex_size: 1000
}
)
end end
let(:current_api_user) { nil } let(:current_api_user) { nil }
@ -156,214 +148,14 @@ RSpec.describe Maps::HexagonRequestHandler do
end end
end end
context 'with H3 enabled via parameter' do
let(:params) do
ActionController::Parameters.new({
min_lon: -74.1,
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',
use_h3: 'true',
h3_resolution: 6
})
end
before do
# Create test points within the date range
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 'uses H3 calculation when enabled' do
result = handle_request
expect(result).to be_a(Hash)
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array)
# H3 calculation might return empty features if points don't create hexagons,
# but if there are features, they should have H3-specific properties
if result['features'].any?
feature = result['features'].first
expect(feature).to be_present
# Only check properties if they exist - some integration paths might
# return features without properties in certain edge cases
if feature['properties'].present?
expect(feature['properties']).to have_key('h3_index')
expect(feature['properties']).to have_key('point_count')
expect(feature['properties']).to have_key('center')
else
# If no properties, this is likely a fallback to non-H3 calculation
# which is acceptable behavior - just verify the feature structure
expect(feature).to have_key('type')
expect(feature).to have_key('geometry')
end
else
# If no features, that's OK - it means the H3 calculation ran but
# didn't produce any hexagons for this data set
expect(result['features']).to eq([])
end
end
end
context 'with H3 enabled via environment variable' do
let(:params) do
ActionController::Parameters.new({
min_lon: -74.1,
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'
})
end
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('HEXAGON_USE_H3').and_return('true')
# Create test points within the date range
3.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 'uses H3 calculation when environment variable is set' do
result = handle_request
expect(result).to be_a(Hash)
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array)
expect(result['features']).not_to be_empty
end
end
context 'when H3 calculation fails' do
let(:params) do
ActionController::Parameters.new({
min_lon: -74.1,
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',
use_h3: 'true'
})
end
before do
# Create test points within the date range
2.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
# Mock H3 calculator to fail
allow_any_instance_of(Maps::H3HexagonCalculator).to receive(:call)
.and_return({ success: false, error: 'H3 error' })
end
it 'falls back to grid calculation when H3 fails' do
result = handle_request
expect(result).to be_a(Hash)
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array)
# Should fall back to grid-based calculation (won't have H3 properties)
if result['features'].any?
feature = result['features'].first
expect(feature).to be_present
if feature['properties'].present?
expect(feature['properties']).not_to have_key('h3_index')
end
end
end
end
context 'H3 resolution validation' do
let(:params) do
ActionController::Parameters.new({
min_lon: -74.1,
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',
use_h3: 'true',
h3_resolution: invalid_resolution
})
end
before do
create(:point,
user:,
latitude: 40.7,
longitude: -74.0,
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
end
context 'with resolution too high' do
let(:invalid_resolution) { 20 }
it 'clamps resolution to maximum valid value' do
# Mock to capture the actual resolution used
calculator_double = instance_double(Maps::H3HexagonCalculator)
allow(Maps::H3HexagonCalculator).to receive(:new) do |user_id, start_date, end_date, resolution|
expect(resolution).to eq(15) # Should be clamped to 15
calculator_double
end
allow(calculator_double).to receive(:call).and_return(
{ success: true, data: { 'type' => 'FeatureCollection', 'features' => [] } }
)
handle_request
end
end
context 'with negative resolution' do
let(:invalid_resolution) { -5 }
it 'clamps resolution to minimum valid value' do
# Mock to capture the actual resolution used
calculator_double = instance_double(Maps::H3HexagonCalculator)
allow(Maps::H3HexagonCalculator).to receive(:new) do |user_id, start_date, end_date, resolution|
expect(resolution).to eq(0) # Should be clamped to 0
calculator_double
end
allow(calculator_double).to receive(:call).and_return(
{ success: true, data: { 'type' => 'FeatureCollection', 'features' => [] } }
)
handle_request
end
end
end
context 'error handling' do context 'error handling' do
let(:params) do let(:params) do
ActionController::Parameters.new({ ActionController::Parameters.new(
uuid: 'invalid-uuid' {
}) uuid: 'invalid-uuid'
}
)
end end
let(:current_api_user) { nil } let(:current_api_user) { nil }
@ -374,4 +166,4 @@ RSpec.describe Maps::HexagonRequestHandler do
end end
end end
end end
end end

View file

@ -23,7 +23,7 @@ RSpec.describe OwnTracks::Importer do
it 'correctly writes attributes' do it 'correctly writes attributes' do
parser parser
point = Point.first point = user.points.first
expect(point.lonlat.x).to be_within(0.001).of(13.332) 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.lonlat.y).to be_within(0.001).of(52.225)
expect(point.attributes.except('lonlat')).to include( expect(point.attributes.except('lonlat')).to include(
@ -75,7 +75,7 @@ RSpec.describe OwnTracks::Importer do
it 'correctly converts speed' do it 'correctly converts speed' do
parser parser
expect(Point.first.velocity).to eq('1.4') expect(user.points.first.velocity).to eq('1.4')
end end
end end

View file

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

View file

@ -15,22 +15,20 @@ RSpec.describe 'Map Interaction', type: :system do
# Create a series of points that form a route # Create a series of points that form a route
[ [
create(:point, user: user, 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), timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user, 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), timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user, 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), timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user, 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) timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
] ]
end end
describe 'Map page interaction' do describe 'Map page interaction' do
context 'when user is signed in' do context 'when user is signed in' do
include_context 'authenticated map user' 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 # The calendar panel JavaScript interaction is complex and may not work
# reliably in headless test environment, but the button should be functional # 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
end end
@ -207,28 +205,30 @@ RSpec.describe 'Map Interaction', type: :system do
else else
# If we can't trigger the popup, at least verify the setup is correct # If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('km') 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 end
end end
context 'with miles distance unit' do 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 let!(:points_for_miles_user) do
# Create a series of points that form a route for the miles user # Create a series of points that form a route for the miles user
[ [
create(:point, user: user_with_miles, 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), timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_miles, 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), timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_miles, 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), timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_miles, 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) timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
] ]
end end
@ -280,7 +280,7 @@ RSpec.describe 'Map Interaction', type: :system do
else else
# If we can't trigger the popup, at least verify the setup is correct # If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') 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 end
end end
@ -288,22 +288,24 @@ RSpec.describe 'Map Interaction', type: :system do
context 'polyline popup content' do context 'polyline popup content' do
context 'with km distance unit' 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 let!(:points_for_km_user) do
# Create a series of points that form a route for the km user # Create a series of points that form a route for the km user
[ [
create(:point, user: user_with_km, 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), timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_km, 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), timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_km, 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), timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_km, 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) timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
] ]
end end
@ -356,28 +358,30 @@ RSpec.describe 'Map Interaction', type: :system do
else else
# If we can't trigger the popup, at least verify the setup is correct # If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('km') 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 end
end end
context 'with miles distance unit' do 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 let!(:points_for_miles_user) do
# Create a series of points that form a route for the miles user # Create a series of points that form a route for the miles user
[ [
create(:point, user: user_with_miles, 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), timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_miles, 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), timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_miles, 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), timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_miles, 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) timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
] ]
end end
@ -429,7 +433,7 @@ RSpec.describe 'Map Interaction', type: :system do
else else
# If we can't trigger the popup, at least verify the setup is correct # If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') 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 end
end end
@ -456,7 +460,7 @@ RSpec.describe 'Map Interaction', type: :system do
click_button 'Update' click_button 'Update'
end end
# Wait for success flash message # Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end end
@ -710,13 +714,13 @@ RSpec.describe 'Map Interaction', type: :system do
it 'allows year selection and month navigation' do it 'allows year selection and month navigation' do
# This test is skipped due to calendar panel JavaScript interaction issues # 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 # 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 end
it 'displays visited cities information' do it 'displays visited cities information' do
# This test is skipped due to calendar panel JavaScript interaction issues # 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 # 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 end
xit 'persists panel state in localStorage' do xit 'persists panel state in localStorage' do