mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Update onborading popup
This commit is contained in:
parent
c67532bb10
commit
5b3fe84933
33 changed files with 635 additions and 479 deletions
|
|
@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
## 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.
|
||||
|
||||
|
||||
# [0.32.0] - 2025-09-13
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
|
|
|
|||
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 |
|
|
@ -4,7 +4,9 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||
|
||||
def index
|
||||
result = Maps::H3HexagonRenderer.call(
|
||||
return unless public_sharing_request? || validate_required_parameters
|
||||
|
||||
result = Maps::HexagonRequestHandler.call(
|
||||
params: params,
|
||||
current_api_user: current_api_user
|
||||
)
|
||||
|
|
@ -28,11 +30,11 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
current_api_user: current_api_user
|
||||
)
|
||||
|
||||
result = Maps::BoundsCalculator.call(
|
||||
result = Maps::BoundsCalculator.new(
|
||||
target_user: context[:target_user],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
)
|
||||
).call
|
||||
|
||||
if result[:success]
|
||||
render json: result[:data]
|
||||
|
|
@ -65,4 +67,39 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
def public_sharing_request?
|
||||
params[:uuid].present?
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
|
|
@ -6,10 +6,6 @@ module Maps
|
|||
class NoDateRangeError < 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:)
|
||||
@target_user = target_user
|
||||
@start_date = start_date
|
||||
|
|
@ -19,8 +15,8 @@ module Maps
|
|||
def call
|
||||
validate_inputs!
|
||||
|
||||
start_timestamp = Maps::DateParameterCoercer.call(@start_date)
|
||||
end_timestamp = Maps::DateParameterCoercer.call(@end_date)
|
||||
start_timestamp = Maps::DateParameterCoercer.new(@start_date).call
|
||||
end_timestamp = Maps::DateParameterCoercer.new(@end_date).call
|
||||
|
||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||
point_count = points_relation.count
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ module Maps
|
|||
class DateParameterCoercer
|
||||
class InvalidDateFormatError < StandardError; end
|
||||
|
||||
def self.call(param)
|
||||
new(param).call
|
||||
end
|
||||
|
||||
def initialize(param)
|
||||
@param = param
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
module Maps
|
||||
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
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
|
|
@ -32,7 +32,8 @@ module Maps
|
|||
attr_reader :user_id, :start_date, :end_date, :h3_resolution
|
||||
|
||||
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.not(lonlat: nil)
|
||||
.select(:id, :lonlat, :timestamp)
|
||||
|
|
@ -81,4 +82,4 @@ module Maps
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Maps::H3HexagonCenters
|
|||
if h3_indexes_with_counts.size > MAX_HEXAGONS
|
||||
Rails.logger.warn "Too many hexagons (#{h3_indexes_with_counts.size}), using lower resolution"
|
||||
# Try with lower resolution (larger hexagons)
|
||||
return recalculate_with_lower_resolution(points)
|
||||
return recalculate_with_lower_resolution
|
||||
end
|
||||
|
||||
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
|
||||
rescue StandardError => e
|
||||
message = "Failed to calculate H3 hexagon centers: #{e.message}"
|
||||
ExceptionReporter.call(e, message) if defined?(ExceptionReporter)
|
||||
ExceptionReporter.call(e, message)
|
||||
raise PostGISError, message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_user_points
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
start_timestamp = Maps::DateParameterCoercer.new(start_date).call
|
||||
end_timestamp = Maps::DateParameterCoercer.new(end_date).call
|
||||
|
||||
Point.where(user_id: user_id)
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.where.not(lonlat: nil)
|
||||
.select(:id, :lonlat, :timestamp)
|
||||
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
|
||||
ExceptionReporter.call(e, e.message) if defined?(ExceptionReporter)
|
||||
raise ArgumentError, e.message
|
||||
end
|
||||
|
||||
def calculate_h3_indexes(points)
|
||||
|
|
@ -86,7 +89,7 @@ class Maps::H3HexagonCenters
|
|||
h3_data
|
||||
end
|
||||
|
||||
def recalculate_with_lower_resolution(points)
|
||||
def recalculate_with_lower_resolution
|
||||
# Try with resolution 2 levels lower (4x larger hexagons)
|
||||
lower_resolution = [h3_resolution - 2, 0].max
|
||||
|
||||
|
|
@ -102,27 +105,9 @@ class Maps::H3HexagonCenters
|
|||
service.call
|
||||
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!
|
||||
return if valid?
|
||||
|
||||
raise InvalidCoordinatesError, errors.full_messages.join(', ')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
|
||||
module Maps
|
||||
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)
|
||||
@params = params
|
||||
@current_api_user = current_api_user
|
||||
|
|
@ -47,7 +43,7 @@ module Maps
|
|||
end
|
||||
|
||||
# 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)
|
||||
end
|
||||
|
||||
|
|
@ -56,14 +52,12 @@ module Maps
|
|||
end_date = parse_date_for_h3(context[:end_date])
|
||||
h3_resolution = params[:h3_resolution]&.to_i&.clamp(0, 15) || 6
|
||||
|
||||
service = Maps::H3HexagonCenters.new(
|
||||
Maps::H3HexagonCenters.new(
|
||||
user_id: context[:target_user]&.id,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
h3_resolution: h3_resolution
|
||||
)
|
||||
|
||||
service.call
|
||||
).call
|
||||
end
|
||||
|
||||
def convert_h3_to_geojson(h3_data)
|
||||
|
|
@ -124,14 +118,14 @@ module Maps
|
|||
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)
|
||||
return Time.zone.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)
|
||||
return Time.zone.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)
|
||||
timestamp = Maps::DateParameterCoercer.new(date_param).call
|
||||
Time.zone.at(timestamp)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,19 +14,22 @@ module Maps
|
|||
def call
|
||||
context = resolve_context
|
||||
|
||||
# Try to use pre-calculated hexagon centers first
|
||||
if context[:stat]
|
||||
# For authenticated users, we need to find the matching stat
|
||||
stat = context[:stat] || find_matching_stat(context)
|
||||
|
||||
# Use pre-calculated hexagon centers
|
||||
if stat
|
||||
cached_result = Maps::HexagonCenterManager.call(
|
||||
stat: context[:stat],
|
||||
stat: stat,
|
||||
target_user: context[:target_user]
|
||||
)
|
||||
|
||||
return cached_result[:data] if cached_result&.dig(:success)
|
||||
end
|
||||
|
||||
# Fall back to on-the-fly calculation
|
||||
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly'
|
||||
generate_hexagons_on_the_fly(context)
|
||||
# No pre-calculated data available - return empty feature collection
|
||||
Rails.logger.debug 'No pre-calculated hexagon centers available'
|
||||
empty_feature_collection
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -40,58 +43,35 @@ module Maps
|
|||
)
|
||||
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(
|
||||
context[:target_user]&.id,
|
||||
start_date,
|
||||
end_date,
|
||||
h3_resolution
|
||||
).call
|
||||
def find_matching_stat(context)
|
||||
return unless context[:target_user] && context[:start_date]
|
||||
|
||||
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
|
||||
Rails.logger.error "H3 calculation failed: #{result[:error]}"
|
||||
empty_feature_collection
|
||||
# Find the stat for this user, year, and month
|
||||
context[:target_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: 'h3'
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => [],
|
||||
'metadata' => {
|
||||
'hexagon_count' => 0,
|
||||
'total_points' => 0,
|
||||
'source' => 'pre_calculated'
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,21 +1,94 @@
|
|||
<% if user_signed_in? %>
|
||||
<div data-controller="onboarding-modal"
|
||||
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
|
||||
data-onboarding-modal-showable-value="true">
|
||||
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Start tracking your location!</h3>
|
||||
<p class="py-4">
|
||||
To start tracking your location and putting it on the map, you need to configure your mobile application.
|
||||
</p>
|
||||
<p>
|
||||
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<div class="modal-box max-w-2xl bg-base-200">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="text-2xl font-bold text-primary mb-2 flex items-center justify-center gap-2">
|
||||
<%= icon 'goal' %> Start Tracking Your Location!</h3>
|
||||
<p class="text-base-content/70">
|
||||
Welcome to Dawarich! Let's get you set up to start tracking and visualizing your location data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-6">
|
||||
<!-- Step 1: Download App -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="badge badge-primary badge-lg">1</div>
|
||||
<h4 class="text-lg font-semibold">Download the Official App</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/80 mb-4">
|
||||
Get the official Dawarich app from the App Store to start tracking your location.
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<%= link_to 'https://apps.apple.com/de/app/dawarich/id6739544999?itscg=30200&itsct=apps_box_badge&mttnsubad=6739544999',
|
||||
class: 'inline-block rounded-lg border-2 border-transparent hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-300 ease-in-out transform hover:scale-105' do %>
|
||||
<%= image_tag 'Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg',
|
||||
class: 'h-12 transition-opacity duration-300' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="badge badge-primary badge-lg">2</div>
|
||||
<h4 class="text-lg font-semibold">Scan QR Code to Connect</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/80 mb-4">
|
||||
Scan this QR code with the Dawarich app to automatically configure your connection.
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white p-3 rounded-lg shadow-inner">
|
||||
<%= api_key_qr_code(current_user, size: 3) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="badge badge-secondary badge-lg">3</div>
|
||||
<h4 class="text-lg font-semibold">Manual Setup (Alternative)</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/80">
|
||||
Alternatively, you can manually grab your API key from
|
||||
<%= link_to 'Settings', settings_path, class: 'link link-primary font-medium' %>
|
||||
and follow the setup instructions in our
|
||||
<%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding',
|
||||
class: 'link link-primary font-medium', target: '_blank', rel: 'noopener' %>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action mt-8">
|
||||
<div class="text-center flex-1">
|
||||
<p class="text-xs text-base-content/60 mb-4">
|
||||
Need help? Check out our
|
||||
<%= link_to 'documentation', 'https://dawarich.app/docs/category/tutorials?utm_source=app&utm_medium=referral&utm_campaign=onboarding',
|
||||
class: 'link link-primary text-xs', target: '_blank', rel: 'noopener' %>
|
||||
for more guidance.
|
||||
</p>
|
||||
</div>
|
||||
<form method="dialog">
|
||||
<button class="btn">Close</button>
|
||||
<button class="btn btn-primary btn-wide">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Got it, let's start!
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal backdrop -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled?
|
||||
if defined?(Rails::Server) && !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled?
|
||||
require 'prometheus_exporter/middleware'
|
||||
require 'prometheus_exporter/instrumentation'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
|||
end
|
||||
|
||||
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(
|
||||
user_id: user_with_points.id,
|
||||
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)
|
||||
|
||||
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 +106,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,
|
||||
|
|
|
|||
158
spec/jobs/points/nightly_reverse_geocoding_job_spec.rb
Normal file
158
spec/jobs/points/nightly_reverse_geocoding_job_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ require 'rails_helper'
|
|||
RSpec.describe Maps::BoundsCalculator do
|
||||
describe '.call' do
|
||||
subject(:calculate_bounds) do
|
||||
described_class.call(
|
||||
described_class.new(
|
||||
target_user: target_user,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
).call
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
|
|
@ -29,16 +29,18 @@ RSpec.describe Maps::BoundsCalculator do
|
|||
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
|
||||
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
|
||||
|
||||
|
|
@ -50,11 +52,13 @@ RSpec.describe Maps::BoundsCalculator do
|
|||
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
|
||||
})
|
||||
expect(calculate_bounds).to match(
|
||||
{
|
||||
success: false,
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -117,4 +121,4 @@ RSpec.describe Maps::BoundsCalculator do
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe Maps::DateParameterCoercer do
|
||||
describe '.call' do
|
||||
subject(:coerce_date) { described_class.call(param) }
|
||||
subject(:coerce_date) { described_class.new(param).call }
|
||||
|
||||
context 'with integer parameter' do
|
||||
let(:param) { 1_717_200_000 }
|
||||
|
|
@ -67,4 +67,4 @@ RSpec.describe Maps::DateParameterCoercer do
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,39 +17,36 @@ RSpec.describe Maps::HexagonRequestHandler do
|
|||
before do
|
||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.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
|
||||
|
||||
context 'with authenticated user and bounding box params' do
|
||||
context 'with authenticated user but no pre-calculated data' 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'
|
||||
})
|
||||
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
|
||||
# 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
|
||||
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 be_an(Array)
|
||||
expect(result['metadata']).to be_present
|
||||
expect(result['features']).to eq([])
|
||||
expect(result['metadata']['hexagon_count']).to eq(0)
|
||||
expect(result['metadata']['source']).to eq('pre_calculated')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -65,14 +62,16 @@ RSpec.describe Maps::HexagonRequestHandler do
|
|||
hexagon_centers: pre_calculated_centers)
|
||||
end
|
||||
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,
|
||||
hex_size: 1000
|
||||
})
|
||||
ActionController::Parameters.new(
|
||||
{
|
||||
uuid: stat.sharing_uuid,
|
||||
min_lon: -74.1,
|
||||
min_lat: 40.6,
|
||||
max_lon: -73.9,
|
||||
max_lat: 40.8,
|
||||
hex_size: 1000
|
||||
}
|
||||
)
|
||||
end
|
||||
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
|
||||
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
|
||||
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,
|
||||
hex_size: 1000
|
||||
})
|
||||
ActionController::Parameters.new(
|
||||
{
|
||||
uuid: stat.sharing_uuid,
|
||||
min_lon: -74.1,
|
||||
min_lat: 40.6,
|
||||
max_lon: -73.9,
|
||||
max_lat: 40.8,
|
||||
hex_size: 1000
|
||||
}
|
||||
)
|
||||
end
|
||||
let(:current_api_user) { nil }
|
||||
|
||||
before 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
|
||||
it 'returns empty feature collection when no pre-calculated centers' do
|
||||
result = handle_request
|
||||
|
||||
expect(result['type']).to eq('FeatureCollection')
|
||||
expect(result['features']).to be_an(Array)
|
||||
expect(result['metadata']).to be_present
|
||||
expect(result['metadata']['pre_calculated']).to be_falsy
|
||||
expect(result['features']).to eq([])
|
||||
expect(result['metadata']['hexagon_count']).to eq(0)
|
||||
expect(result['metadata']['source']).to eq('pre_calculated')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -127,14 +117,16 @@ RSpec.describe Maps::HexagonRequestHandler do
|
|||
hexagon_centers: { 'area_too_large' => true })
|
||||
end
|
||||
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,
|
||||
hex_size: 1000
|
||||
})
|
||||
ActionController::Parameters.new(
|
||||
{
|
||||
uuid: stat.sharing_uuid,
|
||||
min_lon: -74.1,
|
||||
min_lat: 40.6,
|
||||
max_lon: -73.9,
|
||||
max_lat: 40.8,
|
||||
hex_size: 1000
|
||||
}
|
||||
)
|
||||
end
|
||||
let(:current_api_user) { nil }
|
||||
|
||||
|
|
@ -156,214 +148,14 @@ RSpec.describe Maps::HexagonRequestHandler do
|
|||
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
|
||||
let(:params) do
|
||||
ActionController::Parameters.new({
|
||||
uuid: 'invalid-uuid'
|
||||
})
|
||||
ActionController::Parameters.new(
|
||||
{
|
||||
uuid: 'invalid-uuid'
|
||||
}
|
||||
)
|
||||
end
|
||||
let(:current_api_user) { nil }
|
||||
|
||||
|
|
@ -374,4 +166,4 @@ RSpec.describe Maps::HexagonRequestHandler do
|
|||
end
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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') }
|
||||
context 'with miles distance unit' do
|
||||
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
|
||||
|
|
@ -456,7 +460,7 @@ RSpec.describe 'Map Interaction', type: :system do
|
|||
click_button 'Update'
|
||||
end
|
||||
|
||||
# Wait for success flash message
|
||||
# Wait for success flash message
|
||||
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
|
||||
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