diff --git a/app/models/stat.rb b/app/models/stat.rb index 38babb8a..9d25da89 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -57,9 +57,9 @@ class Stat < ApplicationRecord end def hexagons_available? - hexagon_centers.present? && - hexagon_centers.is_a?(Array) && - hexagon_centers.any? + h3_hex_ids.present? && + h3_hex_ids.is_a?(Hash) && + h3_hex_ids.any? end def generate_new_sharing_uuid! diff --git a/app/services/maps/hexagon_center_manager.rb b/app/services/maps/hexagon_center_manager.rb index 9c3d83be..fd699be8 100644 --- a/app/services/maps/hexagon_center_manager.rb +++ b/app/services/maps/hexagon_center_manager.rb @@ -9,7 +9,6 @@ module Maps def call return build_response_from_centers if pre_calculated_centers_available? - return handle_legacy_area_too_large if legacy_area_too_large? nil # No pre-calculated data available end @@ -19,78 +18,59 @@ module Maps attr_reader :stat, :user def pre_calculated_centers_available? - return false if stat&.hexagon_centers.blank? + return false if stat&.h3_hex_ids.blank? - # Handle legacy hash format - if stat.hexagon_centers.is_a?(Hash) - !stat.hexagon_centers['area_too_large'] - else - # Handle array format (actual hexagon centers) - stat.hexagon_centers.is_a?(Array) && stat.hexagon_centers.any? - end - end - - def legacy_area_too_large? - stat&.hexagon_centers.is_a?(Hash) && stat.hexagon_centers['area_too_large'] + stat.h3_hex_ids.is_a?(Hash) && stat.h3_hex_ids.any? end def build_response_from_centers - centers = stat.hexagon_centers - Rails.logger.debug "Using pre-calculated hexagon centers: #{centers.size} centers" + hex_ids = stat.h3_hex_ids + Rails.logger.debug "Using pre-calculated H3 hex IDs: #{hex_ids.size} hexagons" - result = build_hexagons_from_centers(centers) + result = build_hexagons_from_h3_ids(hex_ids) { success: true, data: result, pre_calculated: true } end - def handle_legacy_area_too_large - Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{stat.id}" - - new_centers = recalculate_hexagon_centers - return nil unless new_centers.is_a?(Array) - - update_stat_with_new_centers(new_centers) - end - - def recalculate_hexagon_centers + def recalculate_h3_hex_ids service = Stats::CalculateMonth.new(user.id, stat.year, stat.month) - service.send(:calculate_hexagon_centers) + service.send(:calculate_h3_hex_ids) end - def update_stat_with_new_centers(new_centers) - stat.update(hexagon_centers: new_centers) - result = build_hexagons_from_centers(new_centers) - Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers" + def update_stat_with_new_hex_ids(new_hex_ids) + stat.update(h3_hex_ids: new_hex_ids) + result = build_hexagons_from_h3_ids(new_hex_ids) + Rails.logger.debug "Successfully recalculated H3 hex IDs: #{new_hex_ids.size} hexagons" { success: true, data: result, pre_calculated: true } end - def build_hexagons_from_centers(centers) - # Convert stored centers back to hexagon polygons - hexagon_features = centers.map.with_index { |center, index| build_hexagon_feature(center, index) } + def build_hexagons_from_h3_ids(hex_ids) + # Convert stored H3 IDs back to hexagon polygons + hexagon_features = hex_ids.map.with_index do |(h3_index, data), index| + build_hexagon_feature_from_h3(h3_index, data, index) + end build_feature_collection(hexagon_features) end - def build_hexagon_feature(center, index) - lng, lat, earliest, latest = center + def build_hexagon_feature_from_h3(h3_index, data, index) + count, earliest, latest = data { 'type' => 'Feature', 'id' => index + 1, - 'geometry' => generate_hexagon_geometry(lng, lat), - 'properties' => build_hexagon_properties(index, earliest, latest) + 'geometry' => generate_hexagon_geometry_from_h3(h3_index), + 'properties' => build_hexagon_properties(index, count, earliest, latest) } end - def generate_hexagon_geometry(lng, lat) - Maps::HexagonPolygonGenerator.new( - center_lng: lng, - center_lat: lat - ).call + def generate_hexagon_geometry_from_h3(h3_index) + Maps::HexagonPolygonGenerator.new(h3_index: h3_index).call end - def build_hexagon_properties(index, earliest, latest) + def build_hexagon_properties(index, count, earliest, latest) { 'hex_id' => index + 1, + 'point_count' => count, 'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil, 'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil } diff --git a/app/services/maps/hexagon_polygon_generator.rb b/app/services/maps/hexagon_polygon_generator.rb index b6700aab..29c7efff 100644 --- a/app/services/maps/hexagon_polygon_generator.rb +++ b/app/services/maps/hexagon_polygon_generator.rb @@ -2,10 +2,11 @@ module Maps class HexagonPolygonGenerator - def initialize(center_lng:, center_lat:, h3_resolution: 5) + def initialize(center_lng: nil, center_lat: nil, h3_resolution: 5, h3_index: nil) @center_lng = center_lng @center_lat = center_lat @h3_resolution = h3_resolution + @h3_index = h3_index end def call @@ -14,7 +15,7 @@ module Maps private - attr_reader :center_lng, :center_lat, :h3_resolution + attr_reader :center_lng, :center_lat, :h3_resolution, :h3_index def generate_h3_hexagon_polygon # Convert coordinates to H3 format [lat, lng] diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index 28dd0a39..bd66d4be 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -86,7 +86,7 @@ class Stats::CalculateMonth daily_distance: distance_by_day, distance: distance(distance_by_day), toponyms: toponyms, - hexagon_centers: calculate_hexagon_centers + h3_hex_ids: calculate_h3_hex_ids ) stat.save end @@ -132,22 +132,28 @@ class Stats::CalculateMonth Stat.where(year:, month:, user:).destroy_all end - def calculate_hexagon_centers - return nil if points.empty? + def calculate_h3_hex_ids + return {} if points.empty? begin result = calculate_h3_hexagon_centers if result.empty? - Rails.logger.info "No H3 hexagon centers calculated for user #{user.id}, #{year}-#{month} (no data)" - return nil + Rails.logger.info "No H3 hex IDs calculated for user #{user.id}, #{year}-#{month} (no data)" + return {} end - Rails.logger.info "Pre-calculated #{result.size} H3 hexagon centers for user #{user.id}, #{year}-#{month}" - result + # Convert array format to hash format: { h3_index => [count, earliest, latest] } + hex_hash = result.each_with_object({}) do |hex_data, hash| + h3_index, count, earliest, latest = hex_data + hash[h3_index] = [count, earliest, latest] + end + + Rails.logger.info "Pre-calculated #{hex_hash.size} H3 hex IDs for user #{user.id}, #{year}-#{month}" + hex_hash rescue PostGISError => e - Rails.logger.warn "H3 hexagon centers calculation failed for user #{user.id}, #{year}-#{month}: #{e.message}" - nil + Rails.logger.warn "H3 hex IDs calculation failed for user #{user.id}, #{year}-#{month}: #{e.message}" + {} end end diff --git a/db/migrate/20250913194134_add_hexagon_data_to_stats.rb b/db/migrate/20250913194134_add_hexagon_data_to_stats.rb deleted file mode 100644 index f5c1b97a..00000000 --- a/db/migrate/20250913194134_add_hexagon_data_to_stats.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddHexagonDataToStats < ActiveRecord::Migration[8.0] - def change - add_column :stats, :hexagon_data, :jsonb - end -end diff --git a/db/migrate/20250914094851_add_hexagon_centers_to_stats.rb b/db/migrate/20250914094851_add_hexagon_centers_to_stats.rb deleted file mode 100644 index 9dbc5232..00000000 --- a/db/migrate/20250914094851_add_hexagon_centers_to_stats.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddHexagonCentersToStats < ActiveRecord::Migration[8.0] - def change - add_column :stats, :hexagon_centers, :jsonb - end -end diff --git a/db/migrate/20250914095157_add_index_to_hexagon_centers.rb b/db/migrate/20250914095157_add_index_to_hexagon_centers.rb deleted file mode 100644 index 9e301543..00000000 --- a/db/migrate/20250914095157_add_index_to_hexagon_centers.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddIndexToHexagonCenters < ActiveRecord::Migration[8.0] - disable_ddl_transaction! - - def change - add_index :stats, :hexagon_centers, using: :gin, where: "hexagon_centers IS NOT NULL", algorithm: :concurrently - end -end diff --git a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb new file mode 100644 index 00000000..0ab8a90c --- /dev/null +++ b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddH3HexIdsToStats < ActiveRecord::Migration[8.0] + def change + add_column :stats, :h3_hex_ids, :jsonb, default: {} + add_index :stats, :h3_hex_ids, using: :gin, where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)" + end +end diff --git a/db/schema.rb b/db/schema.rb index 071c1860..cfcab1ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_14_095157) do +ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -222,10 +222,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_14_095157) do t.jsonb "daily_distance", default: {} t.jsonb "sharing_settings", default: {} t.uuid "sharing_uuid" - t.jsonb "hexagon_data" - t.jsonb "hexagon_centers" t.index ["distance"], name: "index_stats_on_distance" - t.index ["hexagon_centers"], name: "index_stats_on_hexagon_centers", where: "(hexagon_centers IS NOT NULL)", using: :gin t.index ["month"], name: "index_stats_on_month" t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true t.index ["user_id"], name: "index_stats_on_user_id" diff --git a/docs/SHAREABLE_STATS_FEATURE.md b/docs/SHAREABLE_STATS_FEATURE.md new file mode 100644 index 00000000..56ddfe19 --- /dev/null +++ b/docs/SHAREABLE_STATS_FEATURE.md @@ -0,0 +1,487 @@ +# Shareable Stats Feature Documentation + +## Overview + +The Shareable Stats feature allows Dawarich users to publicly share their monthly location statistics without requiring authentication. This system provides a secure, time-limited way to share location insights while maintaining user privacy through configurable expiration settings and unguessable UUID-based access. + +## Key Features + +- **Time-based expiration**: Share links can expire after 1 hour, 12 hours, 24 hours, or be permanent +- **UUID-based security**: Each shared stat has a unique, unguessable UUID for secure access +- **Public API access**: Hexagon map data can be accessed via API without authentication when sharing is enabled +- **H3 Hexagon visualization**: Enhanced geographic data visualization using Uber's H3 hexagonal hierarchical spatial index +- **Automatic expiration**: Expired shares are automatically inaccessible +- **Privacy controls**: Users can enable/disable sharing and regenerate sharing URLs at any time + +## Database Schema + +### Stats Table Extensions + +The sharing functionality extends the `stats` table with the following columns: + +```sql +-- Public sharing configuration +sharing_settings JSONB DEFAULT {} +sharing_uuid UUID + +-- Pre-calculated H3 hexagon data for performance +h3_hex_ids JSONB DEFAULT {} + +-- Indexes for performance +INDEX ON h3_hex_ids USING GIN WHERE (h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb) +``` + +### Sharing Settings Structure + +```json +{ + "enabled": true, + "expiration": "24h", // "1h", "12h", "24h", or "permanent" + "expires_at": "2024-01-15T12:00:00Z" +} +``` + +### H3 Hex IDs Data Format + +The `h3_hex_ids` column stores pre-calculated H3 hexagon data as a hash: + +```json +{ + "8a1fb46622dffff": [15, 1640995200, 1640998800], + "8a1fb46622e7fff": [8, 1640996400, 1640999200], + // ... more H3 index entries + // Format: { "h3_index_string": [point_count, earliest_timestamp, latest_timestamp] } +} +``` + +## Architecture Components + +### Models + +#### Stat Model (`app/models/stat.rb`) + +**Key Methods:** +- `sharing_enabled?`: Checks if sharing is enabled +- `sharing_expired?`: Validates expiration status +- `public_accessible?`: Combined check for sharing availability +- `hexagons_available?`: Verifies pre-calculated H3 hex data exists +- `enable_sharing!(expiration:)`: Enables sharing with expiration +- `disable_sharing!`: Disables sharing +- `generate_new_sharing_uuid!`: Regenerates sharing UUID +- `calculate_data_bounds`: Calculates geographic bounds for the month + +### Controllers + +#### Shared::StatsController (`app/controllers/shared/stats_controller.rb`) + +Handles public sharing functionality: + +**Routes:** +- `GET /shared/stats/:uuid` - Public view of shared stats +- `PATCH /stats/:year/:month/sharing` - Sharing management (authenticated) + +**Key Methods:** +- `show`: Renders public stats view without authentication +- `update`: Manages sharing settings (enable/disable, expiration) + +#### Api::V1::Maps::HexagonsController (`app/controllers/api/v1/maps/hexagons_controller.rb`) + +Provides hexagon data for both authenticated and public access: + +**Features:** +- Skip authentication for public sharing requests (`uuid` parameter) +- Context resolution for public vs. authenticated access +- Error handling for missing or expired shares + +```ruby +# Public access via UUID +GET /api/v1/maps/hexagons?uuid=SHARING_UUID + +# Authenticated access +GET /api/v1/maps/hexagons?start_date=2024-01-01&end_date=2024-01-31 +``` + +### Services + +#### Maps::HexagonRequestHandler (`app/services/maps/hexagon_request_handler.rb`) + +Central service for processing hexagon requests: + +**Workflow:** +1. Attempts to find matching stat for the request +2. Delegates to `HexagonCenterManager` for pre-calculated data +3. Returns empty feature collection if no data available + +#### Maps::HexagonCenterManager (`app/services/maps/hexagon_center_manager.rb`) + +Manages pre-calculated H3 hexagon data: + +**Responsibilities:** +- Retrieves pre-calculated H3 hex IDs from database +- Converts stored H3 indexes to GeoJSON polygons +- Builds hexagon features with point counts and timestamps +- Handles efficient polygon generation from H3 indexes + +**Data Flow:** +1. Check if pre-calculated H3 hex IDs are available +2. Convert H3 indexes to hexagon polygons using `HexagonPolygonGenerator` +3. Build GeoJSON FeatureCollection with metadata and point counts + +#### Stats::CalculateMonth (`app/services/stats/calculate_month.rb`) + +Responsible for calculating and storing hexagon data during stats processing: + +**H3 Configuration:** +- `DEFAULT_H3_RESOLUTION = 8`: Small hexagons for good detail +- `MAX_HEXAGONS = 10_000`: Maximum to prevent memory issues + +**Key Methods:** +- `calculate_h3_hex_ids`: Main method for H3 calculation and storage +- `calculate_h3_hexagon_centers`: Internal H3 calculation logic +- `calculate_h3_indexes`: Groups points into H3 hexagons +- `fetch_user_points_for_period`: Retrieves points for date range + +**Algorithm:** +1. Fetch user points for the specified month +2. Convert each point to H3 index at specified resolution +3. Aggregate points per hexagon with count and timestamp bounds +4. Apply resolution reduction if hexagon count exceeds maximum +5. Store as hash of { h3_index_string => [count, earliest, latest] } + +#### Maps::HexagonPolygonGenerator (`app/services/maps/hexagon_polygon_generator.rb`) + +Converts H3 indexes back to polygon geometry: + +**Features:** +- Uses H3 library for accurate hexagon boundaries +- Converts coordinates to GeoJSON Polygon format +- Supports both center-based and H3-index-based generation +- Direct H3 index to polygon conversion for efficiency + +**Usage Modes:** +- **Center-based**: `new(center_lng: lng, center_lat: lat)` + +## H3 Hexagon System + +### What is H3? + +H3 is Uber's Hexagonal Hierarchical Spatial Index that provides: +- **Uniform coverage**: Earth divided into hexagonal cells +- **Hierarchical resolution**: 16 levels from global to local +- **Efficient indexing**: Fast spatial queries and aggregations +- **Consistent shape**: Hexagons have uniform neighbors + +### Resolution Levels + +Dawarich uses H3 resolution 8 by default: +- **Resolution 8**: ~737m average hexagon edge length +- **Fallback mechanism**: Reduces resolution if too many hexagons +- **Maximum limit**: 10,000 hexagons to prevent memory issues + +### Performance Benefits + +1. **Pre-calculation**: H3 hexagons calculated once during stats processing +2. **Efficient storage**: Hash-based storage with H3 index as key +3. **Fast retrieval**: Database lookup instead of real-time calculation +4. **Reduced bandwidth**: Compact JSON hash format for API responses +5. **Direct polygon generation**: H3 index directly converts to polygon boundaries + +## Workflow + +### 1. Stats Calculation Process + +```mermaid +graph TD + A[User Data Import] --> B[Stats::CalculateMonth Service] + B --> C[Calculate H3 Hexagon Centers] + C --> D[Store in hexagon_centers Column] + D --> E[Stats Available for Sharing] +``` + +**Detailed Steps:** +1. User imports location data (GPX, JSON, etc.) +2. Background job triggers `Stats::CalculateMonth` +3. Service calculates monthly statistics including H3 hex IDs +4. H3 indexes are calculated for all points in the month +5. Results stored in `stats.h3_hex_ids` as JSON hash + +### 2. Sharing Activation + +```mermaid +graph TD + A[User Visits Stats Page] --> B[Enable Sharing Toggle] + B --> C[Select Expiration Duration] + C --> D[PATCH /stats/:year/:month/sharing] + D --> E[Generate/Update sharing_uuid] + E --> F[Set sharing_settings] + F --> G[Return Public URL] +``` + +**Sharing Settings:** +- **Expiration options**: 1h, 12h, 24h, permanent +- **UUID generation**: Secure random UUID for each stat +- **Expiration timestamp**: Calculated and stored in sharing_settings + +### 3. Public Access Flow + +```mermaid +graph TD + A[Public User Visits Shared URL] --> B[Validate UUID & Expiration] + B --> C{Valid & Not Expired?} + C -->|Yes| D[Load Public Stats View] + C -->|No| E[Redirect with Error] + D --> F[Render Map with Hexagons] + F --> G[Load Hexagon Data via API] + G --> H[Display Interactive Map] +``` + +**Security Checks:** +1. Verify sharing UUID exists in database +2. Check `sharing_settings.enabled = true` +3. Validate expiration timestamp if not permanent +4. Return 404 if any check fails + +### 4. Hexagon Data Retrieval + +```mermaid +graph TD + A[Map Requests Hexagon Data] --> B[GET /api/v1/maps/hexagons?uuid=UUID] + B --> C[HexagonsController] + C --> D[Skip Authentication for UUID Request] + D --> E[HexagonRequestHandler] + E --> F[Find Stat by UUID] + F --> G[HexagonCenterManager] + G --> H[Load Pre-calculated Centers] + H --> I[Convert to GeoJSON Polygons] + I --> J[Return FeatureCollection] +``` + +**Data Transformation:** +1. Retrieve stored H3 hex IDs hash from database +2. Convert each H3 index to hexagon boundary coordinates +3. Build GeoJSON Feature with properties (point count, timestamps) +4. Return complete FeatureCollection for map rendering + +## API Endpoints + +### Public Sharing + +#### View Shared Stats +```http +GET /shared/stats/:uuid +``` +- **Authentication**: None required +- **Response**: HTML page with public stats view +- **Error Handling**: Redirects to root with alert if invalid/expired + +#### Get Hexagon Data +```http +GET /api/v1/maps/hexagons?uuid=:uuid +``` +- **Authentication**: None required for UUID access +- **Response**: GeoJSON FeatureCollection +- **Features**: Each feature represents one hexagon with point count and timestamps + +### Authenticated Management + +#### Toggle Sharing +```http +PATCH /stats/:year/:month/sharing +``` +**Parameters:** +- `enabled`: "1" to enable, "0" to disable +- `expiration`: "1h", "12h", "24h", or "permanent" (when enabling) + +**Response:** +```json +{ + "success": true, + "sharing_url": "https://domain.com/shared/stats/uuid", + "message": "Sharing enabled successfully" +} +``` + +## Security Features + +### UUID-based Access +- **Unguessable URLs**: Uses secure random UUIDs +- **No enumeration**: Can't guess valid sharing links +- **Automatic generation**: New UUID created for each sharing activation + +### Time-based Expiration +- **Configurable duration**: Multiple expiration options +- **Automatic enforcement**: Expired shares become inaccessible +- **Precise timestamping**: ISO8601 format with timezone awareness + +### Limited Data Exposure +- **No user identification**: Public view doesn't expose user details +- **Aggregated data only**: Only statistical summaries are shared +- **No raw location points**: Individual coordinates not exposed + +### Privacy Controls +- **User control**: Users can enable/disable sharing at any time +- **UUID regeneration**: Can generate new sharing URL to invalidate old ones +- **Granular permissions**: Per-month sharing control + +## Frontend Integration + +### Public View Template (`app/views/stats/public_month.html.erb`) + +**Features:** +- **Responsive design**: Mobile-friendly layout with Tailwind CSS +- **Monthly statistics**: Distance, active days, countries visited +- **Interactive hexagon map**: Leaflet.js with H3 hexagon overlay +- **Activity charts**: Daily distance visualization +- **Location summary**: Countries and cities visited + +**Map Integration:** +```erb +