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

View file

@ -12,6 +12,7 @@ This file contains essential information for Claude to work effectively with the
- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos)
- Export to GeoJSON and GPX formats
- Statistics and analytics (countries visited, distance traveled, etc.)
- Public sharing of monthly statistics with time-based expiration
- Trips management with photo integration
- Areas and visits tracking
- Integration with photo management systems (Immich, Photoprism)
@ -75,7 +76,7 @@ This file contains essential information for Claude to work effectively with the
- **Trip**: User-defined travel periods with analytics
- **Import**: Data import operations
- **Export**: Data export operations
- **Stat**: Calculated statistics and metrics
- **Stat**: Calculated statistics and metrics with public sharing capabilities
### Geographic Features
- Uses PostGIS for advanced geographic queries
@ -126,11 +127,41 @@ npx playwright test # E2E tests
- Various import jobs for different data sources
- Statistical calculation jobs
## Public Sharing System
### Overview
Dawarich includes a comprehensive public sharing system that allows users to share their monthly statistics with others without requiring authentication. This feature enables users to showcase their location data while maintaining privacy control through configurable expiration settings.
### Key Features
- **Time-based expiration**: Share links can expire after 1 hour, 12 hours, 24 hours, or be permanent
- **UUID-based access**: Each shared stat has a unique, unguessable UUID for security
- **Public API endpoints**: Hexagon map data can be accessed via API without authentication when sharing is enabled
- **Automatic cleanup**: Expired shares are automatically inaccessible
- **Privacy controls**: Users can enable/disable sharing and regenerate sharing URLs at any time
### Technical Implementation
- **Database**: `sharing_settings` (JSONB) and `sharing_uuid` (UUID) columns on `stats` table
- **Routes**: `/shared/stats/:uuid` for public viewing, `/stats/:year/:month/sharing` for management
- **API**: `/api/v1/maps/hexagons` supports public access via `uuid` parameter
- **Controllers**: `Shared::StatsController` handles public views, sharing management integrated into existing stats flow
### Security Features
- **No authentication bypass**: Public sharing only exposes specifically designed endpoints
- **UUID-based access**: Sharing URLs use unguessable UUIDs rather than sequential IDs
- **Expiration enforcement**: Automatic expiration checking prevents access to expired shares
- **Limited data exposure**: Only monthly statistics and hexagon data are publicly accessible
### Usage Patterns
- **Social sharing**: Users can share interesting travel months with friends and family
- **Portfolio/showcase**: Travel bloggers and photographers can showcase location statistics
- **Data collaboration**: Researchers can share aggregated location data for analysis
- **Public demonstrations**: Demo instances can provide public examples without compromising user data
## API Documentation
- **Framework**: rSwag (Swagger/OpenAPI)
- **Location**: `/api-docs` endpoint
- **Authentication**: API key (Bearer) for API access
- **Authentication**: API key (Bearer) for API access, UUID-based access for public shares
## Database Schema
@ -142,7 +173,7 @@ npx playwright test # E2E tests
- `visits` - Detected area visits
- `trips` - Travel periods
- `imports`/`exports` - Data transfer operations
- `stats` - Calculated metrics
- `stats` - Calculated metrics with sharing capabilities (`sharing_settings`, `sharing_uuid`)
### PostGIS Integration
- Extensive use of PostGIS geometry types
@ -201,6 +232,11 @@ bundle exec bundle-audit # Dependency security
4. **Testing**: Include both unit and integration tests for location-based features
5. **Performance**: Consider database indexes for geographic queries
6. **Security**: Never log or expose user location data inappropriately
7. **Public Sharing**: When implementing features that interact with stats, consider public sharing access patterns:
- Use `public_accessible?` method to check if a stat can be publicly accessed
- Support UUID-based access in API endpoints when appropriate
- Respect expiration settings and disable sharing when expired
- Only expose minimal necessary data in public sharing contexts
## Contributing

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
let(:area) { create(:area, user: user) }
it 'calls the AreaVisitsCalculationService' do
allow(User).to receive(:find_each).and_yield(user)
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
described_class.new.perform

View file

@ -23,8 +23,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
end
before do
# Remove any leftover users from other tests, keeping only our test users
User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
allow(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end
@ -69,8 +67,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
end
before do
# Remove any leftover users from other tests, keeping only our test users
User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
allow(Stats::BulkCalculator).to receive(:new).and_call_original
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
end

View file

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

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)
trial_user.update!(points_count: trial_user.points.count)
# Mock User.active_or_trial to only return test users
active_or_trial_mock = double('ActiveRecord::Relation')
allow(User).to receive(:active_or_trial).and_return(active_or_trial_mock)
allow(active_or_trial_mock).to receive(:find_each).and_yield(active_user).and_yield(trial_user)
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end

View file

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

View file

@ -6,7 +6,7 @@ RSpec.describe Api::UserSerializer do
describe '#call' do
subject(:serializer) { described_class.new(user).call }
let(:user) { create(:user, email: 'test@example.com', theme: 'dark') }
let(:user) { create(:user) }
it 'returns JSON with correct user attributes' do
expect(serializer[:user][:email]).to eq(user.email)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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