diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..722dcc68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Dawarich is a Rails 8 monolith. Controllers, models, jobs, services, policies, and Stimulus/Turbo JS live in `app/`, while shared POROs sit in `lib/`. Configuration, credentials, and cron/Sidekiq settings live in `config/`; API documentation assets are in `swagger/`. Database migrations and seeds live in `db/`, Docker tooling sits in `docker/`, and docs or media live in `docs/` and `screenshots/`. Runtime artifacts in `storage/`, `tmp/`, and `log/` stay untracked. + +## Architecture & Key Services +The stack pairs Rails 8 with PostgreSQL + PostGIS, Redis-backed Sidekiq, Devise/Pundit, Tailwind + DaisyUI, and Leaflet/Chartkick. Imports, exports, sharing, and trip analytics lean on PostGIS geometries plus workers, so queue anything non-trivial instead of blocking requests. + +## Build, Test, and Development Commands +- `docker compose -f docker/docker-compose.yml up` — launches the full stack for smoke tests. +- `bundle exec rails db:prepare` — create/migrate the PostGIS database. +- `bundle exec bin/dev` and `bundle exec sidekiq` — start the web/Vite/Tailwind stack and workers locally. +- `make test` — runs Playwright (`npx playwright test e2e --workers=1`) then `bundle exec rspec`. +- `bundle exec rubocop` / `npx prettier --check app/javascript` — enforce formatting before commits. + +## Coding Style & Naming Conventions +Use two-space indentation, snake_case filenames, and CamelCase classes. Keep Stimulus controllers under `app/javascript/controllers/*_controller.ts` so names match DOM `data-controller` hooks. Prefer service objects in `app/services/` for multi-step imports/exports, and let migrations named like `202405061210_add_indexes_to_events` manage schema changes. Follow Tailwind ordering conventions and avoid bespoke CSS unless necessary. + +## Testing Guidelines +RSpec mirrors the app hierarchy inside `spec/` with files suffixed `_spec.rb`; rely on FactoryBot/FFaker for data, WebMock for HTTP, and SimpleCov for coverage. Browser journeys live in `e2e/` and should use `data-testid` selectors plus seeded demo data to reset state. Run `make test` before pushing and document intentional gaps when coverage dips. + +## Commit & Pull Request Guidelines +Write short, imperative commit subjects (`Add globe_projection setting`) and include the PR/issue reference like `(#2138)` when relevant. Target `dev`, describe migrations, configs, and verification steps, and attach screenshots or curl examples for UI/API work. Link related Discussions for larger changes and request review from domain owners (imports, sharing, trips, etc.). + +## Security & Configuration Tips +Start from `.env.example` or `.env.template` and store secrets in encrypted Rails credentials; never commit files from `gps-env/` or real trace data. Rotate API keys, scrub sensitive coordinates in fixtures, and use the synthetic traces in `db/seeds.rb` when demonstrating imports. diff --git a/app/controllers/api/v1/tracks_controller.rb b/app/controllers/api/v1/tracks_controller.rb new file mode 100644 index 00000000..862f5236 --- /dev/null +++ b/app/controllers/api/v1/tracks_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Api::V1::TracksController < ApiController + def index + tracks = current_api_user.tracks + + # Date range filtering (overlap logic) + if params[:start_at].present? && params[:end_at].present? + start_at = Time.zone.parse(params[:start_at]) + end_at = Time.zone.parse(params[:end_at]) + + # Show tracks that overlap: end_at >= start_filter AND start_at <= end_filter + tracks = tracks.where('end_at >= ? AND start_at <= ?', start_at, end_at) + end + + # Pagination (Kaminari) + tracks = tracks + .order(start_at: :desc) + .page(params[:page]) + .per(params[:per_page] || 100) + + # Serialize to GeoJSON format + features = tracks.map do |track| + { + type: 'Feature', + geometry: RGeo::GeoJSON.encode(track.original_path), + properties: { + id: track.id, + color: '#ff0000', # Red color + start_at: track.start_at.iso8601, + end_at: track.end_at.iso8601, + distance: track.distance.to_i, + avg_speed: track.avg_speed.to_f, + duration: track.duration + } + } + end + + geojson = { + type: 'FeatureCollection', + features: features + } + + # Add pagination headers + response.set_header('X-Current-Page', tracks.current_page.to_s) + response.set_header('X-Total-Pages', tracks.total_pages.to_s) + response.set_header('X-Total-Count', tracks.total_count.to_s) + + render json: geojson + end +end diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js index 141bb148..e6425f36 100644 --- a/app/javascript/controllers/maps/maplibre/data_loader.js +++ b/app/javascript/controllers/maps/maplibre/data_loader.js @@ -105,10 +105,17 @@ export class DataLoader { } data.placesGeoJSON = this.placesToGeoJSON(data.places) - // Tracks - DISABLED: Backend API not yet implemented - // TODO: Re-enable when /api/v1/tracks endpoint is created - data.tracks = [] - data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks) + // Fetch tracks + try { + data.tracksGeoJSON = await this.api.fetchTracks({ + start_at: startDate, + end_at: endDate + }) + console.log('[Tracks] Fetched tracks:', data.tracksGeoJSON.features.length, 'tracks') + } catch (error) { + console.warn('[Tracks] Failed to fetch tracks (non-blocking):', error.message) + data.tracksGeoJSON = { type: 'FeatureCollection', features: [] } + } return data } diff --git a/app/javascript/maps_maplibre/services/api_client.js b/app/javascript/maps_maplibre/services/api_client.js index 33b49f82..6346c283 100644 --- a/app/javascript/maps_maplibre/services/api_client.js +++ b/app/javascript/maps_maplibre/services/api_client.js @@ -290,10 +290,22 @@ export class ApiClient { } /** - * Fetch tracks + * Fetch tracks for a single page + * @param {Object} options - { start_at, end_at, page, per_page } + * @returns {Promise} { features, currentPage, totalPages, totalCount } */ - async fetchTracks() { - const response = await fetch(`${this.baseURL}/tracks`, { + async fetchTracksPage({ start_at, end_at, page = 1, per_page = 100 }) { + const params = new URLSearchParams({ + page: page.toString(), + per_page: per_page.toString() + }) + + if (start_at) params.append('start_at', start_at) + if (end_at) params.append('end_at', end_at) + + const url = `${this.baseURL}/tracks?${params.toString()}` + + const response = await fetch(url, { headers: this.getHeaders() }) @@ -301,7 +313,48 @@ export class ApiClient { throw new Error(`Failed to fetch tracks: ${response.statusText}`) } - return response.json() + const geojson = await response.json() + + return { + features: geojson.features, + currentPage: parseInt(response.headers.get('X-Current-Page') || '1'), + totalPages: parseInt(response.headers.get('X-Total-Pages') || '1'), + totalCount: parseInt(response.headers.get('X-Total-Count') || '0') + } + } + + /** + * Fetch all tracks (handles pagination automatically) + * @param {Object} options - { start_at, end_at, onProgress } + * @returns {Promise} GeoJSON FeatureCollection + */ + async fetchTracks({ start_at, end_at, onProgress } = {}) { + let allFeatures = [] + let currentPage = 1 + let totalPages = 1 + + while (currentPage <= totalPages) { + const { features, totalPages: tp } = await this.fetchTracksPage({ + start_at, + end_at, + page: currentPage, + per_page: 100 + }) + + allFeatures = allFeatures.concat(features) + totalPages = tp + + if (onProgress) { + onProgress(currentPage, totalPages) + } + + currentPage++ + } + + return { + type: 'FeatureCollection', + features: allFeatures + } } /** diff --git a/app/views/map/maplibre/_settings_panel.html.erb b/app/views/map/maplibre/_settings_panel.html.erb index 0a5cc062..38296300 100644 --- a/app/views/map/maplibre/_settings_panel.html.erb +++ b/app/views/map/maplibre/_settings_panel.html.erb @@ -278,7 +278,7 @@
- <%#
+
-

Show saved tracks

-
%> +

Show backend-calculated tracks in red

+
- <%#
%> +
diff --git a/config/routes.rb b/config/routes.rb index 64ec52ba..e081a999 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,6 +193,8 @@ Rails.application.routes.draw do end end + resources :tracks, only: [:index] + namespace :maps do resources :tile_usage, only: [:create] resources :hexagons, only: [:index] do diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh index 5d1f7143..e426938f 100644 --- a/docker/web-entrypoint.sh +++ b/docker/web-entrypoint.sh @@ -82,5 +82,32 @@ bundle exec rake data:migrate echo "Running seeds..." bundle exec rails db:seed +# Optionally start prometheus exporter alongside the web process +PROMETHEUS_EXPORTER_PID="" +if [ "$PROMETHEUS_EXPORTER_ENABLED" = "true" ]; then + PROM_HOST=${PROMETHEUS_EXPORTER_HOST:-0.0.0.0} + PROM_PORT=${PROMETHEUS_EXPORTER_PORT:-9394} + + case "$PROM_HOST" in + ""|"0.0.0.0"|"::"|"127.0.0.1"|"localhost"|"ANY") + echo "📈 Starting Prometheus exporter on ${PROM_HOST:-0.0.0.0}:${PROM_PORT}..." + bundle exec prometheus_exporter -b "${PROM_HOST:-ANY}" -p "${PROM_PORT}" & + PROMETHEUS_EXPORTER_PID=$! + + cleanup() { + if [ -n "$PROMETHEUS_EXPORTER_PID" ] && kill -0 "$PROMETHEUS_EXPORTER_PID" 2>/dev/null; then + echo "🛑 Stopping Prometheus exporter (PID $PROMETHEUS_EXPORTER_PID)..." + kill "$PROMETHEUS_EXPORTER_PID" + wait "$PROMETHEUS_EXPORTER_PID" 2>/dev/null || true + fi + } + trap cleanup EXIT INT TERM + ;; + *) + echo "ℹ️ PROMETHEUS_EXPORTER_HOST is set to $PROM_HOST, skipping embedded exporter startup." + ;; + esac +fi + # run passed commands -bundle exec ${@} +bundle exec "$@" diff --git a/e2e/v2/map/layers/tracks.spec.js b/e2e/v2/map/layers/tracks.spec.js new file mode 100644 index 00000000..8a848deb --- /dev/null +++ b/e2e/v2/map/layers/tracks.spec.js @@ -0,0 +1,423 @@ +import { test, expect } from '@playwright/test' +import { closeOnboardingModal } from '../../../helpers/navigation.js' +import { + navigateToMapsV2WithDate, + waitForMapLibre, + waitForLoadingComplete, + hasLayer, + getLayerVisibility +} from '../../helpers/setup.js' + +test.describe('Tracks Layer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/map/v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59') + await closeOnboardingModal(page) + await waitForMapLibre(page) + await waitForLoadingComplete(page) + await page.waitForTimeout(1500) + }) + + test.describe('Toggle', () => { + test('tracks layer toggle exists', async ({ page }) => { + // Open settings panel + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + + // Click Layers tab + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await expect(tracksToggle).toBeVisible() + }) + + test('tracks toggle is unchecked by default', async ({ page }) => { + // Open settings panel + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + + // Click Layers tab + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + const isChecked = await tracksToggle.isChecked() + expect(isChecked).toBe(false) + }) + + test('can toggle tracks layer on', async ({ page }) => { + // Open settings panel + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + + // Click Layers tab + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(500) + + const isChecked = await tracksToggle.isChecked() + expect(isChecked).toBe(true) + }) + + test('can toggle tracks layer off', async ({ page }) => { + // Open settings panel + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + + // Click Layers tab + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + + // Turn on + await tracksToggle.check() + await page.waitForTimeout(500) + expect(await tracksToggle.isChecked()).toBe(true) + + // Turn off + await tracksToggle.uncheck() + await page.waitForTimeout(500) + expect(await tracksToggle.isChecked()).toBe(false) + }) + }) + + test.describe('Layer Visibility', () => { + test('tracks layer is hidden by default', async ({ page }) => { + // Wait for tracks layer to be added to the map + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return false + const app = window.Stimulus || window.Application + if (!app) return false + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + return controller?.map?.getLayer('tracks') !== undefined + }, { timeout: 10000 }).catch(() => false) + + // Check that tracks layer is not visible on the map + const tracksVisible = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return null + + const app = window.Stimulus || window.Application + if (!app) return null + + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return null + + const layer = controller.map.getLayer('tracks') + if (!layer) return null + + return controller.map.getLayoutProperty('tracks', 'visibility') === 'visible' + }) + + expect(tracksVisible).toBe(false) + }) + + test('tracks layer becomes visible when toggled on', async ({ page }) => { + // Open settings and enable tracks + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(500) + + // Verify layer is visible + const tracksVisible = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return null + + const app = window.Stimulus || window.Application + if (!app) return null + + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return null + + const layer = controller.map.getLayer('tracks') + if (!layer) return null + + return controller.map.getLayoutProperty('tracks', 'visibility') === 'visible' + }) + + expect(tracksVisible).toBe(true) + }) + }) + + test.describe('Toggle Persistence', () => { + test('tracks toggle state persists after page reload', async ({ page }) => { + // Enable tracks + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(2000) // Wait for API save to complete + + // Reload page + await page.reload() + await closeOnboardingModal(page) + await waitForMapLibre(page) + await waitForLoadingComplete(page) + await page.waitForTimeout(2000) // Wait for settings to load and layers to initialize + + // Verify tracks layer is actually visible (which means the setting persisted) + const tracksVisible = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return null + + const app = window.Stimulus || window.Application + if (!app) return null + + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return null + + const layer = controller.map.getLayer('tracks') + if (!layer) return null + + return controller.map.getLayoutProperty('tracks', 'visibility') === 'visible' + }) + + expect(tracksVisible).toBe(true) + }) + }) + + test.describe('Layer Existence', () => { + test('tracks layer exists on map', async ({ page }) => { + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return false + const app = window.Stimulus || window.Application + if (!app) return false + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + return controller?.map?.getLayer('tracks') !== undefined + }, { timeout: 10000 }).catch(() => false) + + const hasTracksLayer = await hasLayer(page, 'tracks') + expect(hasTracksLayer).toBe(true) + }) + }) + + test.describe('Data Source', () => { + test('tracks source has data', async ({ page }) => { + // Enable tracks layer first + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(1000) + + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return false + const app = window.Stimulus || window.Application + if (!app) return false + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + return controller?.map?.getSource('tracks-source') !== undefined + }, { timeout: 20000 }) + + const tracksData = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return null + const app = window.Stimulus || window.Application + if (!app) return null + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return null + + const source = controller.map.getSource('tracks-source') + if (!source) return { hasSource: false, featureCount: 0, features: [] } + + const data = source._data + return { + hasSource: true, + featureCount: data?.features?.length || 0, + features: data?.features || [] + } + }) + + expect(tracksData.hasSource).toBe(true) + expect(tracksData.featureCount).toBeGreaterThanOrEqual(0) + }) + + test('tracks have LineString geometry', async ({ page }) => { + // Enable tracks layer first + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(1000) + + const tracksData = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return { features: [] } + const app = window.Stimulus || window.Application + if (!app) return { features: [] } + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return { features: [] } + + const source = controller.map.getSource('tracks-source') + const data = source?._data + return { features: data?.features || [] } + }) + + if (tracksData.features.length > 0) { + tracksData.features.forEach(feature => { + expect(feature.geometry.type).toBe('LineString') + expect(feature.geometry.coordinates.length).toBeGreaterThan(1) + }) + } + }) + + test('tracks have red color property', async ({ page }) => { + // Enable tracks layer first + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(1000) + + const tracksData = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return { features: [] } + const app = window.Stimulus || window.Application + if (!app) return { features: [] } + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return { features: [] } + + const source = controller.map.getSource('tracks-source') + const data = source?._data + return { features: data?.features || [] } + }) + + if (tracksData.features.length > 0) { + tracksData.features.forEach(feature => { + expect(feature.properties).toHaveProperty('color') + expect(feature.properties.color).toBe('#ff0000') // Red color + }) + } + }) + + test('tracks have metadata properties', async ({ page }) => { + // Enable tracks layer first + await page.locator('[data-action="click->maps--maplibre#toggleSettings"]').first().click() + await page.waitForTimeout(200) + await page.locator('button[data-tab="layers"]').click() + await page.waitForTimeout(200) + const tracksToggle = page.locator('label:has-text("Tracks")').first().locator('input.toggle') + await tracksToggle.check() + await page.waitForTimeout(1000) + + const tracksData = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return { features: [] } + const app = window.Stimulus || window.Application + if (!app) return { features: [] } + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return { features: [] } + + const source = controller.map.getSource('tracks-source') + const data = source?._data + return { features: data?.features || [] } + }) + + if (tracksData.features.length > 0) { + tracksData.features.forEach(feature => { + expect(feature.properties).toHaveProperty('id') + expect(feature.properties).toHaveProperty('start_at') + expect(feature.properties).toHaveProperty('end_at') + expect(feature.properties).toHaveProperty('distance') + expect(feature.properties).toHaveProperty('avg_speed') + expect(feature.properties).toHaveProperty('duration') + expect(typeof feature.properties.distance).toBe('number') + expect(feature.properties.distance).toBeGreaterThanOrEqual(0) + }) + } + }) + }) + + test.describe('Styling', () => { + test('tracks have red color styling', async ({ page }) => { + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return false + const app = window.Stimulus || window.Application + if (!app) return false + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + return controller?.map?.getLayer('tracks') !== undefined + }, { timeout: 20000 }) + + const trackLayerInfo = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return null + + const app = window.Stimulus || window.Application + if (!app) return null + + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + if (!controller?.map) return null + + const layer = controller.map.getLayer('tracks') + if (!layer) return null + + const lineColor = controller.map.getPaintProperty('tracks', 'line-color') + + return { + exists: !!lineColor, + isArray: Array.isArray(lineColor), + value: lineColor + } + }) + + expect(trackLayerInfo).toBeTruthy() + expect(trackLayerInfo.exists).toBe(true) + + // Track color uses ['get', 'color'] expression to read from feature properties + // Features have color: '#ff0000' set by the backend + if (trackLayerInfo.isArray) { + // It's a MapLibre expression like ['get', 'color'] + expect(trackLayerInfo.value).toContain('get') + expect(trackLayerInfo.value).toContain('color') + } + }) + }) + + test.describe('Date Navigation', () => { + test('date navigation preserves tracks layer', async ({ page }) => { + // Wait for tracks layer to be added to the map + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + if (!element) return false + const app = window.Stimulus || window.Application + if (!app) return false + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + return controller?.map?.getLayer('tracks') !== undefined + }, { timeout: 10000 }) + + const initialTracks = await hasLayer(page, 'tracks') + expect(initialTracks).toBe(true) + + await navigateToMapsV2WithDate(page, '2025-10-16T00:00', '2025-10-16T23:59') + await closeOnboardingModal(page) + + await waitForMapLibre(page) + await waitForLoadingComplete(page) + await page.waitForTimeout(1500) + + const hasTracksLayer = await hasLayer(page, 'tracks') + expect(hasTracksLayer).toBe(true) + }) + }) +}) diff --git a/lib/tasks/demo.rake b/lib/tasks/demo.rake index e51889d7..07bae0af 100644 --- a/lib/tasks/demo.rake +++ b/lib/tasks/demo.rake @@ -72,7 +72,12 @@ namespace :demo do created_areas = create_areas(user, 10) puts "✅ Created #{created_areas} areas" - # 6. Create family with members + # 6. Create tracks + puts "\n🛤️ Creating 20 tracks..." + created_tracks = create_tracks(user, 20) + puts "✅ Created #{created_tracks} tracks" + + # 7. Create family with members puts "\n👨‍👩‍👧‍👦 Creating demo family..." family_members = create_family_with_members(user) puts "✅ Created family with #{family_members.count} members" @@ -87,6 +92,7 @@ namespace :demo do puts " Suggested Visits: #{user.visits.suggested.count}" puts " Confirmed Visits: #{user.visits.confirmed.count}" puts " Areas: #{user.areas.count}" + puts " Tracks: #{user.tracks.count}" puts " Family Members: #{family_members.count}" puts "\n🔐 Login credentials:" puts ' Email: demo@dawarich.app' @@ -321,4 +327,105 @@ namespace :demo do family_members end + + def create_tracks(user, count) + # Get points that aren't already assigned to tracks + available_points = Point.where(user_id: user.id, track_id: nil) + .order(:timestamp) + + if available_points.count < 10 + puts " ⚠️ Not enough untracked points to create tracks" + return 0 + end + + created_count = 0 + points_per_track = [available_points.count / count, 10].max + + count.times do |index| + # Get a segment of consecutive points + offset = index * points_per_track + track_points = available_points.offset(offset).limit(points_per_track).to_a + + break if track_points.length < 2 + + # Sort by timestamp to ensure proper ordering + track_points = track_points.sort_by(&:timestamp) + + # Build LineString from points + coordinates = track_points.map { |p| [p.lon, p.lat] } + linestring_wkt = "LINESTRING(#{coordinates.map { |lon, lat| "#{lon} #{lat}" }.join(', ')})" + + # Calculate track metadata + start_at = Time.zone.at(track_points.first.timestamp) + end_at = Time.zone.at(track_points.last.timestamp) + duration = (end_at - start_at).to_i + + # Calculate total distance + total_distance = 0 + track_points.each_cons(2) do |p1, p2| + total_distance += haversine_distance(p1.lat, p1.lon, p2.lat, p2.lon) + end + + # Calculate average speed (m/s) + avg_speed = duration > 0 ? (total_distance / duration.to_f) : 0 + + # Calculate elevation data + elevations = track_points.map(&:altitude).compact + elevation_gain = 0 + elevation_loss = 0 + elevation_max = elevations.any? ? elevations.max : 0 + elevation_min = elevations.any? ? elevations.min : 0 + + if elevations.length > 1 + elevations.each_cons(2) do |alt1, alt2| + diff = alt2 - alt1 + if diff > 0 + elevation_gain += diff + else + elevation_loss += diff.abs + end + end + end + + # Create the track + track = user.tracks.create!( + start_at: start_at, + end_at: end_at, + distance: total_distance, + avg_speed: avg_speed, + duration: duration, + elevation_gain: elevation_gain, + elevation_loss: elevation_loss, + elevation_max: elevation_max, + elevation_min: elevation_min, + original_path: linestring_wkt + ) + + # Associate points with the track + track_points.each { |p| p.update_column(:track_id, track.id) } + + created_count += 1 + print '.' if (index + 1) % 5 == 0 + end + + puts '' if created_count > 0 + created_count + end + + def haversine_distance(lat1, lon1, lat2, lon2) + # Haversine formula to calculate distance in meters + rad_per_deg = Math::PI / 180 + rm = 6371000 # Earth radius in meters + + dlat_rad = (lat2 - lat1) * rad_per_deg + dlon_rad = (lon2 - lon1) * rad_per_deg + + lat1_rad = lat1 * rad_per_deg + lat2_rad = lat2 * rad_per_deg + + a = Math.sin(dlat_rad / 2)**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon_rad / 2)**2 + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + rm * c # Distance in meters + end end diff --git a/spec/requests/api/v1/tracks_spec.rb b/spec/requests/api/v1/tracks_spec.rb new file mode 100644 index 00000000..bbd994e5 --- /dev/null +++ b/spec/requests/api/v1/tracks_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe '/api/v1/tracks', type: :request do + let(:user) { create(:user) } + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + + describe 'GET /index' do + let!(:track1) do + create(:track, user: user, + start_at: Time.zone.parse('2024-01-01 10:00'), + end_at: Time.zone.parse('2024-01-01 12:00')) + end + let!(:track2) do + create(:track, user: user, + start_at: Time.zone.parse('2024-01-02 10:00'), + end_at: Time.zone.parse('2024-01-02 12:00')) + end + let!(:other_user_track) { create(:track) } # Different user + + it 'returns successful response' do + get api_v1_tracks_url, headers: headers + expect(response).to be_successful + end + + it 'returns GeoJSON FeatureCollection format' do + get api_v1_tracks_url, headers: headers + json = JSON.parse(response.body) + + expect(json['type']).to eq('FeatureCollection') + expect(json['features']).to be_an(Array) + end + + it 'returns only current user tracks' do + get api_v1_tracks_url, headers: headers + json = JSON.parse(response.body) + + expect(json['features'].length).to eq(2) + track_ids = json['features'].map { |f| f['properties']['id'] } + expect(track_ids).to contain_exactly(track1.id, track2.id) + expect(track_ids).not_to include(other_user_track.id) + end + + it 'includes red color in feature properties' do + get api_v1_tracks_url, headers: headers + json = JSON.parse(response.body) + + json['features'].each do |feature| + expect(feature['properties']['color']).to eq('#ff0000') + end + end + + it 'includes GeoJSON geometry' do + get api_v1_tracks_url, headers: headers + json = JSON.parse(response.body) + + json['features'].each do |feature| + expect(feature['geometry']).to be_present + expect(feature['geometry']['type']).to eq('LineString') + expect(feature['geometry']['coordinates']).to be_an(Array) + end + end + + it 'includes track metadata in properties' do + get api_v1_tracks_url, headers: headers + json = JSON.parse(response.body) + + feature = json['features'].first + expect(feature['properties']).to include( + 'id', 'color', 'start_at', 'end_at', 'distance', 'avg_speed', 'duration' + ) + end + + it 'sets pagination headers' do + get api_v1_tracks_url, headers: headers + + expect(response.headers['X-Current-Page']).to be_present + expect(response.headers['X-Total-Pages']).to be_present + expect(response.headers['X-Total-Count']).to be_present + end + + context 'with pagination parameters' do + before do + create_list(:track, 5, user: user) + end + + it 'respects per_page parameter' do + get api_v1_tracks_url, params: { per_page: 2 }, headers: headers + json = JSON.parse(response.body) + + expect(json['features'].length).to eq(2) + expect(response.headers['X-Total-Pages'].to_i).to be > 1 + end + + it 'respects page parameter' do + get api_v1_tracks_url, params: { page: 2, per_page: 2 }, headers: headers + + expect(response.headers['X-Current-Page']).to eq('2') + end + end + + context 'with date range filtering' do + it 'returns tracks that overlap with date range' do + get api_v1_tracks_url, params: { + start_at: '2024-01-01T00:00:00', + end_at: '2024-01-01T23:59:59' + }, headers: headers + + json = JSON.parse(response.body) + expect(json['features'].length).to eq(1) + expect(json['features'].first['properties']['id']).to eq(track1.id) + end + + it 'includes tracks that start before and end after range' do + long_track = create(:track, user: user, + start_at: Time.zone.parse('2024-01-01 08:00'), + end_at: Time.zone.parse('2024-01-03 20:00')) + + get api_v1_tracks_url, params: { + start_at: '2024-01-02T00:00:00', + end_at: '2024-01-02T23:59:59' + }, headers: headers + + json = JSON.parse(response.body) + track_ids = json['features'].map { |f| f['properties']['id'] } + expect(track_ids).to include(long_track.id, track2.id) + end + + it 'excludes tracks outside date range' do + get api_v1_tracks_url, params: { + start_at: '2024-01-05T00:00:00', + end_at: '2024-01-05T23:59:59' + }, headers: headers + + json = JSON.parse(response.body) + expect(json['features']).to be_empty + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get api_v1_tracks_url + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user has no tracks' do + let(:user_without_tracks) { create(:user) } + + it 'returns empty FeatureCollection' do + get api_v1_tracks_url, headers: { 'Authorization' => "Bearer #{user_without_tracks.api_key}" } + json = JSON.parse(response.body) + + expect(json['type']).to eq('FeatureCollection') + expect(json['features']).to eq([]) + end + end + end +end