mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 17:51:39 -05:00
Add tracks to map v2
This commit is contained in:
parent
6e9e02388b
commit
e8dc4df1a9
10 changed files with 870 additions and 14 deletions
26
AGENTS.md
Normal file
26
AGENTS.md
Normal file
|
|
@ -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.
|
||||
51
app/controllers/api/v1/tracks_controller.rb
Normal file
51
app/controllers/api/v1/tracks_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Object>} { 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<Object>} 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@
|
|||
<div class="divider"></div>
|
||||
|
||||
<!-- Tracks Layer -->
|
||||
<%# <div class="form-control">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
|
|
@ -286,10 +286,10 @@
|
|||
data-action="change->maps--maplibre#toggleTracks" />
|
||||
<span class="label-text font-medium">Tracks</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show saved tracks</p>
|
||||
</div> %>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show backend-calculated tracks in red</p>
|
||||
</div>
|
||||
|
||||
<%# <div class="divider"></div> %>
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Fog of War Layer -->
|
||||
<div class="form-control">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
423
e2e/v2/map/layers/tracks.spec.js
Normal file
423
e2e/v2/map/layers/tracks.spec.js
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
160
spec/requests/api/v1/tracks_spec.rb
Normal file
160
spec/requests/api/v1/tracks_spec.rb
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue