Add tracks to map v2

This commit is contained in:
Eugene Burmakin 2026-01-10 14:13:03 +01:00
parent 6e9e02388b
commit e8dc4df1a9
10 changed files with 870 additions and 14 deletions

26
AGENTS.md Normal file
View 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.

View 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

View file

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

View file

@ -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
}
}
/**

View file

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

View file

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

View file

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

View 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)
})
})
})

View file

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

View 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