mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Add tracks calculation and storage in the database
This commit is contained in:
parent
fd4b785a19
commit
862f601e1d
29 changed files with 1710 additions and 24 deletions
|
|
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
## Added
|
||||
|
||||
- In the User Settings -> Background Jobs, you can now enable or disable visits suggestions. It's a background task that runs every day at midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.
|
||||
- Tracks are now being calculated and stored in the database instead of being calculated on the fly in the browser. This will make the map page load faster.
|
||||
|
||||
## Changed
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
39
app/controllers/api/v1/tracks_controller.rb
Normal file
39
app/controllers/api/v1/tracks_controller.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TracksController < ApiController
|
||||
def index
|
||||
start_time = parse_timestamp(params[:start_at])
|
||||
end_time = parse_timestamp(params[:end_at])
|
||||
|
||||
# Find tracks that overlap with the date range
|
||||
@tracks = current_api_user.tracks
|
||||
.where('start_at <= ? AND end_at >= ?', end_time, start_time)
|
||||
.order(:start_at)
|
||||
|
||||
render json: { tracks: @tracks }
|
||||
end
|
||||
|
||||
def create
|
||||
tracks_created = Tracks::CreateFromPoints.new(current_api_user).call
|
||||
|
||||
render json: {
|
||||
message: "#{tracks_created} tracks created successfully",
|
||||
tracks_created: tracks_created
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_timestamp(timestamp_param)
|
||||
return Time.current if timestamp_param.blank?
|
||||
|
||||
# Handle both Unix timestamps and ISO date strings
|
||||
if timestamp_param.to_s.match?(/^\d+$/)
|
||||
Time.zone.at(timestamp_param.to_i)
|
||||
else
|
||||
Time.zone.parse(timestamp_param)
|
||||
end
|
||||
rescue ArgumentError
|
||||
Time.current
|
||||
end
|
||||
end
|
||||
|
|
@ -6,9 +6,31 @@ class MapController < ApplicationController
|
|||
def index
|
||||
@points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
|
||||
@coordinates =
|
||||
@points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country)
|
||||
.map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] }
|
||||
@coordinates = []
|
||||
# @points.pluck(:lonlat, :battery, :altitude, :timestamp, :velocity, :id, :country)
|
||||
# .map { |lonlat, *rest| [lonlat.y, lonlat.x, *rest.map(&:to_s)] }
|
||||
tracks_data = current_user.tracks
|
||||
.where('start_at <= ? AND end_at >= ?', Time.zone.at(end_at), Time.zone.at(start_at))
|
||||
.order(start_at: :asc)
|
||||
.pluck(:id, :start_at, :end_at, :distance, :avg_speed, :duration,
|
||||
:elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path)
|
||||
|
||||
@tracks = tracks_data.map do |id, start_at, end_at, distance, avg_speed, duration,
|
||||
elevation_gain, elevation_loss, elevation_max, elevation_min, original_path|
|
||||
{
|
||||
id: id,
|
||||
start_at: start_at.iso8601,
|
||||
end_at: end_at.iso8601,
|
||||
distance: distance&.to_f || 0,
|
||||
avg_speed: avg_speed&.to_f || 0,
|
||||
duration: duration || 0,
|
||||
elevation_gain: elevation_gain || 0,
|
||||
elevation_loss: elevation_loss || 0,
|
||||
elevation_max: elevation_max || 0,
|
||||
elevation_min: elevation_min || 0,
|
||||
original_path: original_path&.to_s
|
||||
}
|
||||
end
|
||||
@distance = distance
|
||||
@start_at = Time.zone.at(start_at)
|
||||
@end_at = Time.zone.at(end_at)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ import {
|
|||
colorStopsFallback
|
||||
} from "../maps/polylines";
|
||||
|
||||
import {
|
||||
createTracksLayer,
|
||||
updateTracksOpacity,
|
||||
toggleTracksVisibility,
|
||||
filterTracks,
|
||||
trackColorPalette
|
||||
} from "../maps/tracks";
|
||||
|
||||
import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas";
|
||||
|
||||
import { showFlashMessage, fetchAndDisplayPhotos } from "../maps/helpers";
|
||||
|
|
@ -34,6 +42,8 @@ export default class extends BaseController {
|
|||
visitedCitiesCache = new Map();
|
||||
trackedMonthsCache = null;
|
||||
currentPopup = null;
|
||||
tracksLayer = null;
|
||||
tracksVisible = false;
|
||||
|
||||
connect() {
|
||||
super.connect();
|
||||
|
|
@ -41,9 +51,33 @@ export default class extends BaseController {
|
|||
|
||||
this.apiKey = this.element.dataset.api_key;
|
||||
this.selfHosted = this.element.dataset.self_hosted;
|
||||
this.markers = JSON.parse(this.element.dataset.coordinates);
|
||||
|
||||
// Defensive JSON parsing with error handling
|
||||
try {
|
||||
this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : [];
|
||||
} catch (error) {
|
||||
console.error('Error parsing coordinates data:', error);
|
||||
console.error('Raw coordinates data:', this.element.dataset.coordinates);
|
||||
this.markers = [];
|
||||
}
|
||||
|
||||
try {
|
||||
this.tracksData = this.element.dataset.tracks ? JSON.parse(this.element.dataset.tracks) : null;
|
||||
} catch (error) {
|
||||
console.error('Error parsing tracks data:', error);
|
||||
console.error('Raw tracks data:', this.element.dataset.tracks);
|
||||
this.tracksData = null;
|
||||
}
|
||||
|
||||
this.timezone = this.element.dataset.timezone;
|
||||
this.userSettings = JSON.parse(this.element.dataset.user_settings);
|
||||
|
||||
try {
|
||||
this.userSettings = this.element.dataset.user_settings ? JSON.parse(this.element.dataset.user_settings) : {};
|
||||
} catch (error) {
|
||||
console.error('Error parsing user_settings data:', error);
|
||||
console.error('Raw user_settings data:', this.element.dataset.user_settings);
|
||||
this.userSettings = {};
|
||||
}
|
||||
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
|
||||
this.fogLinethreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90;
|
||||
// Store route opacity as decimal (0-1) internally
|
||||
|
|
@ -55,7 +89,14 @@ export default class extends BaseController {
|
|||
this.speedColoredPolylines = this.userSettings.speed_colored_routes || false;
|
||||
this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback);
|
||||
|
||||
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
|
||||
// Ensure we have valid markers array
|
||||
if (!Array.isArray(this.markers)) {
|
||||
console.warn('Markers is not an array, setting to empty array');
|
||||
this.markers = [];
|
||||
}
|
||||
|
||||
// Set default center (Berlin) if no markers available
|
||||
this.center = this.markers.length > 0 ? this.markers[this.markers.length - 1] : [52.514568, 13.350111];
|
||||
|
||||
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
|
||||
|
||||
|
|
@ -102,6 +143,9 @@ export default class extends BaseController {
|
|||
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
|
||||
|
||||
// Initialize empty tracks layer for layer control (will be populated later)
|
||||
this.tracksLayer = L.layerGroup();
|
||||
|
||||
// Create a proper Leaflet layer for fog
|
||||
this.fogOverlay = createFogOverlay();
|
||||
|
||||
|
|
@ -142,6 +186,7 @@ export default class extends BaseController {
|
|||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
Routes: this.polylinesLayer,
|
||||
Tracks: this.tracksLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer,
|
||||
|
|
@ -154,6 +199,9 @@ export default class extends BaseController {
|
|||
// Initialize layer control first
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
|
||||
// Now initialize tracks data (after layer control is created)
|
||||
this.initializeTracksLayer();
|
||||
|
||||
// Add the toggle panel button
|
||||
this.addTogglePanelButton();
|
||||
|
||||
|
|
@ -801,6 +849,17 @@ export default class extends BaseController {
|
|||
<input type="checkbox" id="speed_colored_routes" name="speed_colored_routes" class='w-4' style="width: 20px;" ${this.speedColoredRoutesChecked()} />
|
||||
</label>
|
||||
|
||||
<hr class="my-2">
|
||||
|
||||
<h4 style="font-weight: bold; margin: 8px 0;">Track Settings</h4>
|
||||
|
||||
<label for="tracks_visible">
|
||||
Show Tracks
|
||||
<input type="checkbox" id="tracks_visible" name="tracks_visible" class='w-4' style="width: 20px;" ${this.tracksVisible ? 'checked' : ''} />
|
||||
</label>
|
||||
|
||||
<button type="button" id="refresh-tracks-btn" class="btn btn-xs mt-2">Refresh Tracks</button>
|
||||
|
||||
<label for="speed_color_scale">Speed color scale</label>
|
||||
<div class="join">
|
||||
<input type="text" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="speed_color_scale" name="speed_color_scale" min="5" max="100" step="1" value="${this.speedColorScale}">
|
||||
|
|
@ -829,6 +888,17 @@ export default class extends BaseController {
|
|||
editBtn.addEventListener("click", this.showGradientEditor.bind(this));
|
||||
}
|
||||
|
||||
// Add track control event listeners
|
||||
const tracksVisibleCheckbox = div.querySelector("#tracks_visible");
|
||||
if (tracksVisibleCheckbox) {
|
||||
tracksVisibleCheckbox.addEventListener("change", this.toggleTracksVisibility.bind(this));
|
||||
}
|
||||
|
||||
const refreshTracksBtn = div.querySelector("#refresh-tracks-btn");
|
||||
if (refreshTracksBtn) {
|
||||
refreshTracksBtn.addEventListener("click", this.refreshTracks.bind(this));
|
||||
}
|
||||
|
||||
// Add event listener to the form submission
|
||||
div.querySelector('#settings-form').addEventListener(
|
||||
'submit', this.updateSettings.bind(this)
|
||||
|
|
@ -953,6 +1023,7 @@ export default class extends BaseController {
|
|||
const layerStates = {
|
||||
Points: this.map.hasLayer(this.markersLayer),
|
||||
Routes: this.map.hasLayer(this.polylinesLayer),
|
||||
Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false,
|
||||
Heatmap: this.map.hasLayer(this.heatmapLayer),
|
||||
"Fog of War": this.map.hasLayer(this.fogOverlay),
|
||||
"Scratch map": this.map.hasLayer(this.scratchLayer),
|
||||
|
|
@ -969,6 +1040,7 @@ export default class extends BaseController {
|
|||
const controlsLayer = {
|
||||
Points: this.markersLayer || L.layerGroup(),
|
||||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Tracks: this.tracksLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.heatLayer([]),
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer || L.layerGroup(),
|
||||
|
|
@ -1557,4 +1629,194 @@ export default class extends BaseController {
|
|||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Track-related methods
|
||||
async initializeTracksLayer() {
|
||||
console.log('DEBUG: Initializing tracks layer');
|
||||
console.log('DEBUG: this.tracksData:', this.tracksData);
|
||||
console.log('DEBUG: tracksData type:', typeof this.tracksData);
|
||||
console.log('DEBUG: tracksData length:', this.tracksData ? this.tracksData.length : 'undefined');
|
||||
|
||||
// Use pre-loaded tracks data if available, otherwise fetch from API
|
||||
if (this.tracksData && this.tracksData.length > 0) {
|
||||
console.log('DEBUG: Using pre-loaded tracks data');
|
||||
this.createTracksFromData(this.tracksData);
|
||||
} else {
|
||||
console.log('DEBUG: No pre-loaded tracks data, fetching from API');
|
||||
await this.fetchTracks();
|
||||
}
|
||||
|
||||
console.log('DEBUG: Tracks layer after initialization:', this.tracksLayer);
|
||||
}
|
||||
|
||||
async fetchTracks() {
|
||||
try {
|
||||
// Get start and end dates from the current map view or URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startAt = urlParams.get('start_at') || this.getDefaultStartDate();
|
||||
const endAt = urlParams.get('end_at') || this.getDefaultEndDate();
|
||||
|
||||
const response = await fetch(`/api/v1/tracks?start_at=${startAt}&end_at=${endAt}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.createTracksFromData(data.tracks || []);
|
||||
} else {
|
||||
console.warn('Failed to fetch tracks:', response.status);
|
||||
// Create empty layer for layer control
|
||||
this.tracksLayer = L.layerGroup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Tracks API not available or failed:', error);
|
||||
// Create empty layer for layer control
|
||||
this.tracksLayer = L.layerGroup();
|
||||
}
|
||||
}
|
||||
|
||||
createTracksFromData(tracksData) {
|
||||
// Clear existing tracks
|
||||
this.tracksLayer.clearLayers();
|
||||
|
||||
console.log('DEBUG: Creating tracks from data:', {
|
||||
tracksData: tracksData,
|
||||
tracksCount: tracksData ? tracksData.length : 0,
|
||||
firstTrack: tracksData && tracksData.length > 0 ? tracksData[0] : null
|
||||
});
|
||||
|
||||
if (!tracksData || tracksData.length === 0) {
|
||||
console.log('DEBUG: No tracks data available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tracks layer with data and add to existing tracks layer
|
||||
const newTracksLayer = createTracksLayer(
|
||||
tracksData,
|
||||
this.map,
|
||||
this.userSettings,
|
||||
this.distanceUnit
|
||||
);
|
||||
|
||||
console.log('DEBUG: Created tracks layer:', newTracksLayer);
|
||||
|
||||
// Add all tracks to the existing tracks layer
|
||||
newTracksLayer.eachLayer((layer) => {
|
||||
this.tracksLayer.addLayer(layer);
|
||||
});
|
||||
|
||||
console.log('DEBUG: Final tracks layer with', Object.keys(this.tracksLayer._layers).length, 'layers');
|
||||
}
|
||||
|
||||
updateLayerControl() {
|
||||
if (!this.layerControl) return;
|
||||
|
||||
// Remove existing layer control
|
||||
this.map.removeControl(this.layerControl);
|
||||
|
||||
// Create new controls layer object
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer || L.layerGroup(),
|
||||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Tracks: this.tracksLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.heatLayer([]),
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer || L.layerGroup(),
|
||||
Areas: this.areasLayer || L.layerGroup(),
|
||||
Photos: this.photoMarkers || L.layerGroup(),
|
||||
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
|
||||
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
|
||||
};
|
||||
|
||||
// Re-add the layer control
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
}
|
||||
|
||||
toggleTracksVisibility(event) {
|
||||
this.tracksVisible = event.target.checked;
|
||||
|
||||
if (this.tracksLayer) {
|
||||
toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
getDefaultStartDate() {
|
||||
// Default to last week if no markers available
|
||||
if (!this.markers || this.markers.length === 0) {
|
||||
return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// Get start date from first marker
|
||||
const firstMarker = this.markers[0];
|
||||
if (firstMarker && firstMarker[3]) {
|
||||
const startDate = new Date(firstMarker[3] * 1000);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
return startDate.toISOString();
|
||||
}
|
||||
|
||||
return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
getDefaultEndDate() {
|
||||
// Default to today if no markers available
|
||||
if (!this.markers || this.markers.length === 0) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// Get end date from last marker
|
||||
const lastMarker = this.markers[this.markers.length - 1];
|
||||
if (lastMarker && lastMarker[3]) {
|
||||
const endDate = new Date(lastMarker[3] * 1000);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
return endDate.toISOString();
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async refreshTracks() {
|
||||
const refreshBtn = document.getElementById('refresh-tracks-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.textContent = 'Refreshing...';
|
||||
}
|
||||
|
||||
try {
|
||||
// Trigger track creation on backend
|
||||
const response = await fetch(`/api/v1/tracks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: this.apiKey
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showFlashMessage('notice', data.message || 'Tracks refreshed successfully');
|
||||
|
||||
// Refresh tracks display
|
||||
await this.fetchTracks();
|
||||
} else {
|
||||
throw new Error('Failed to refresh tracks');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing tracks:', error);
|
||||
showFlashMessage('error', 'Failed to refresh tracks');
|
||||
} finally {
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.textContent = 'Refresh Tracks';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,25 @@ export function minutesToDaysHoursMinutes(minutes) {
|
|||
}
|
||||
|
||||
export function formatDate(timestamp, timezone) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
let date;
|
||||
|
||||
// Handle both Unix timestamps (numbers) and ISO8601 strings
|
||||
if (typeof timestamp === 'number') {
|
||||
// Unix timestamp in seconds, convert to milliseconds
|
||||
date = new Date(timestamp * 1000);
|
||||
} else if (typeof timestamp === 'string') {
|
||||
// ISO8601 string, parse directly
|
||||
date = new Date(timestamp);
|
||||
} else {
|
||||
// Invalid input
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
let locale;
|
||||
if (navigator.languages !== undefined) {
|
||||
locale = navigator.languages[0];
|
||||
|
|
|
|||
382
app/javascript/maps/tracks.js
Normal file
382
app/javascript/maps/tracks.js
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
import { formatDate } from "../maps/helpers";
|
||||
import { formatDistance } from "../maps/helpers";
|
||||
import { formatSpeed } from "../maps/helpers";
|
||||
import { minutesToDaysHoursMinutes } from "../maps/helpers";
|
||||
|
||||
// Track-specific color palette - different from regular polylines
|
||||
export const trackColorPalette = {
|
||||
default: 'blue', // Green - distinct from blue polylines
|
||||
hover: '#FF6B35', // Orange-red for hover
|
||||
active: '#E74C3C', // Red for active/clicked
|
||||
start: '#2ECC71', // Green for start marker
|
||||
end: '#E67E22' // Orange for end marker
|
||||
};
|
||||
|
||||
export function getTrackColor() {
|
||||
// All tracks use the same default color
|
||||
return trackColorPalette.default;
|
||||
}
|
||||
|
||||
export function createTrackPopupContent(track, distanceUnit) {
|
||||
const startTime = formatDate(track.start_at, 'UTC');
|
||||
const endTime = formatDate(track.end_at, 'UTC');
|
||||
const duration = track.duration || 0;
|
||||
const durationFormatted = minutesToDaysHoursMinutes(Math.round(duration / 60));
|
||||
|
||||
return `
|
||||
<div class="track-popup">
|
||||
<h4 class="track-popup-title">📍 Track #${track.id}</h4>
|
||||
<div class="track-info">
|
||||
<strong>🕐 Start:</strong> ${startTime}<br>
|
||||
<strong>🏁 End:</strong> ${endTime}<br>
|
||||
<strong>⏱️ Duration:</strong> ${durationFormatted}<br>
|
||||
<strong>📏 Distance:</strong> ${formatDistance(track.distance / 1000, distanceUnit)}<br>
|
||||
<strong>⚡ Avg Speed:</strong> ${formatSpeed(track.avg_speed, distanceUnit)}<br>
|
||||
<strong>⛰️ Elevation:</strong> +${track.elevation_gain || 0}m / -${track.elevation_loss || 0}m<br>
|
||||
<strong>📊 Max Alt:</strong> ${track.elevation_max || 0}m<br>
|
||||
<strong>📉 Min Alt:</strong> ${track.elevation_min || 0}m
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit) {
|
||||
let hoverPopup = null;
|
||||
let isClicked = false;
|
||||
|
||||
// Create start and end markers
|
||||
const startIcon = L.divIcon({
|
||||
html: "🚀",
|
||||
className: "track-start-icon emoji-icon",
|
||||
iconSize: [20, 20]
|
||||
});
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
html: "🎯",
|
||||
className: "track-end-icon emoji-icon",
|
||||
iconSize: [20, 20]
|
||||
});
|
||||
|
||||
// Get first and last coordinates from the track path
|
||||
const coordinates = getTrackCoordinates(track);
|
||||
if (!coordinates || coordinates.length < 2) return;
|
||||
|
||||
const startCoord = coordinates[0];
|
||||
const endCoord = coordinates[coordinates.length - 1];
|
||||
|
||||
const startMarker = L.marker([startCoord[0], startCoord[1]], { icon: startIcon });
|
||||
const endMarker = L.marker([endCoord[0], endCoord[1]], { icon: endIcon });
|
||||
|
||||
function handleTrackHover(e) {
|
||||
if (isClicked) return; // Don't change hover state if clicked
|
||||
|
||||
// Apply hover style to all segments in the track
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: trackColorPalette.hover,
|
||||
weight: 6,
|
||||
opacity: 0.9
|
||||
});
|
||||
layer.bringToFront();
|
||||
}
|
||||
});
|
||||
|
||||
// Show markers and popup
|
||||
startMarker.addTo(map);
|
||||
endMarker.addTo(map);
|
||||
|
||||
const popupContent = createTrackPopupContent(track, distanceUnit);
|
||||
|
||||
if (hoverPopup) {
|
||||
map.closePopup(hoverPopup);
|
||||
}
|
||||
|
||||
hoverPopup = L.popup()
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(popupContent)
|
||||
.addTo(map);
|
||||
}
|
||||
|
||||
function handleTrackMouseOut(e) {
|
||||
if (isClicked) return; // Don't reset if clicked
|
||||
|
||||
// Reset to original style
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: layer.options.originalColor,
|
||||
weight: 4,
|
||||
opacity: userSettings.route_opacity || 0.7
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remove markers and popup
|
||||
if (hoverPopup) {
|
||||
map.closePopup(hoverPopup);
|
||||
map.removeLayer(startMarker);
|
||||
map.removeLayer(endMarker);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackClick(e) {
|
||||
e.originalEvent.stopPropagation();
|
||||
|
||||
// Toggle clicked state
|
||||
isClicked = !isClicked;
|
||||
|
||||
if (isClicked) {
|
||||
// Apply clicked style
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: trackColorPalette.active,
|
||||
weight: 8,
|
||||
opacity: 1
|
||||
});
|
||||
layer.bringToFront();
|
||||
}
|
||||
});
|
||||
|
||||
startMarker.addTo(map);
|
||||
endMarker.addTo(map);
|
||||
|
||||
// Show persistent popup
|
||||
const popupContent = createTrackPopupContent(track, distanceUnit);
|
||||
|
||||
L.popup()
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(popupContent)
|
||||
.addTo(map);
|
||||
|
||||
// Store reference for cleanup
|
||||
trackGroup._isTrackClicked = true;
|
||||
trackGroup._trackStartMarker = startMarker;
|
||||
trackGroup._trackEndMarker = endMarker;
|
||||
} else {
|
||||
// Reset to hover state or original state
|
||||
handleTrackMouseOut(e);
|
||||
trackGroup._isTrackClicked = false;
|
||||
if (trackGroup._trackStartMarker) map.removeLayer(trackGroup._trackStartMarker);
|
||||
if (trackGroup._trackEndMarker) map.removeLayer(trackGroup._trackEndMarker);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners to all layers in the track group
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.on('mouseover', handleTrackHover);
|
||||
layer.on('mouseout', handleTrackMouseOut);
|
||||
layer.on('click', handleTrackClick);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when clicking elsewhere on map
|
||||
map.on('click', function() {
|
||||
if (trackGroup._isTrackClicked) {
|
||||
isClicked = false;
|
||||
trackGroup._isTrackClicked = false;
|
||||
handleTrackMouseOut({ latlng: [0, 0] });
|
||||
if (trackGroup._trackStartMarker) map.removeLayer(trackGroup._trackStartMarker);
|
||||
if (trackGroup._trackEndMarker) map.removeLayer(trackGroup._trackEndMarker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTrackCoordinates(track) {
|
||||
// Add debugging to see what we're working with
|
||||
console.log(`DEBUG: Parsing track ${track.id}:`, {
|
||||
has_coordinates: !!(track.coordinates && Array.isArray(track.coordinates)),
|
||||
has_path: !!(track.path && Array.isArray(track.path)),
|
||||
original_path_type: typeof track.original_path,
|
||||
original_path_length: track.original_path ? track.original_path.length : 0,
|
||||
original_path_sample: track.original_path ? track.original_path.substring(0, 100) + '...' : null
|
||||
});
|
||||
|
||||
// First check if coordinates are already provided as an array
|
||||
if (track.coordinates && Array.isArray(track.coordinates)) {
|
||||
console.log(`DEBUG: Using coordinates array for track ${track.id}`);
|
||||
return track.coordinates; // If already provided as array of [lat, lng]
|
||||
}
|
||||
|
||||
// If coordinates are provided as a path property
|
||||
if (track.path && Array.isArray(track.path)) {
|
||||
console.log(`DEBUG: Using path array for track ${track.id}`);
|
||||
return track.path;
|
||||
}
|
||||
|
||||
// Try to parse from original_path (PostGIS LineString format)
|
||||
if (track.original_path && typeof track.original_path === 'string') {
|
||||
try {
|
||||
console.log(`DEBUG: Attempting to parse original_path for track ${track.id}: "${track.original_path}"`);
|
||||
|
||||
// Parse PostGIS LineString format: "LINESTRING (lng lat, lng lat, ...)" or "LINESTRING(lng lat, lng lat, ...)"
|
||||
const match = track.original_path.match(/LINESTRING\s*\(([^)]+)\)/i);
|
||||
if (match) {
|
||||
console.log(`DEBUG: LineString match found for track ${track.id}: "${match[1]}"`);
|
||||
const coordString = match[1];
|
||||
const coordinates = coordString.split(',').map(pair => {
|
||||
const [lng, lat] = pair.trim().split(/\s+/).map(parseFloat);
|
||||
if (isNaN(lng) || isNaN(lat)) {
|
||||
console.warn(`Invalid coordinates in track ${track.id}: "${pair.trim()}"`);
|
||||
return null;
|
||||
}
|
||||
return [lat, lng]; // Return as [lat, lng] for Leaflet
|
||||
}).filter(Boolean); // Remove null entries
|
||||
|
||||
console.log(`DEBUG: Parsed ${coordinates.length} coordinates for track ${track.id}`);
|
||||
|
||||
if (coordinates.length >= 2) {
|
||||
return coordinates;
|
||||
} else {
|
||||
console.warn(`Track ${track.id} has only ${coordinates.length} valid coordinates`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`No LINESTRING match found for track ${track.id}. Raw: "${track.original_path}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse track original_path for track ${track.id}:`, error);
|
||||
console.error(`Raw original_path: "${track.original_path}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// For development/testing, create a simple line if we have start/end coordinates
|
||||
if (track.start_point && track.end_point) {
|
||||
console.log(`DEBUG: Using start/end points for track ${track.id}`);
|
||||
return [
|
||||
[track.start_point.lat, track.start_point.lng],
|
||||
[track.end_point.lat, track.end_point.lng]
|
||||
];
|
||||
}
|
||||
|
||||
console.warn('Track coordinates not available for track', track.id);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function createTracksLayer(tracks, map, userSettings, distanceUnit) {
|
||||
// Create a custom pane for tracks with higher z-index than regular polylines
|
||||
if (!map.getPane('tracksPane')) {
|
||||
map.createPane('tracksPane');
|
||||
map.getPane('tracksPane').style.zIndex = 460; // Above polylines pane (450)
|
||||
}
|
||||
|
||||
const renderer = L.canvas({
|
||||
padding: 0.5,
|
||||
pane: 'tracksPane'
|
||||
});
|
||||
|
||||
const trackLayers = tracks.map((track) => {
|
||||
const coordinates = getTrackCoordinates(track);
|
||||
|
||||
if (!coordinates || coordinates.length < 2) {
|
||||
console.warn(`Track ${track.id} has insufficient coordinates`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackColor = getTrackColor();
|
||||
const trackGroup = L.featureGroup();
|
||||
|
||||
// Create polyline segments for the track
|
||||
// For now, create a single polyline, but this could be segmented for elevation/speed coloring
|
||||
const trackPolyline = L.polyline(coordinates, {
|
||||
renderer: renderer,
|
||||
color: trackColor,
|
||||
originalColor: trackColor,
|
||||
opacity: userSettings.route_opacity || 0.7,
|
||||
weight: 4,
|
||||
interactive: true,
|
||||
pane: 'tracksPane',
|
||||
bubblingMouseEvents: false,
|
||||
trackId: track.id
|
||||
});
|
||||
|
||||
trackGroup.addLayer(trackPolyline);
|
||||
|
||||
// Add interactions
|
||||
addTrackInteractions(trackGroup, map, track, userSettings, distanceUnit);
|
||||
|
||||
// Store track data for reference
|
||||
trackGroup._trackData = track;
|
||||
|
||||
return trackGroup;
|
||||
}).filter(Boolean); // Remove null entries
|
||||
|
||||
// Create the main layer group
|
||||
const tracksLayerGroup = L.layerGroup(trackLayers);
|
||||
|
||||
// Add CSS for track styling
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.leaflet-tracksPane-pane {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.leaflet-tracksPane-pane canvas {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.track-popup {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.track-popup-title {
|
||||
margin: 0 0 8px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 16px;
|
||||
}
|
||||
.track-info {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.track-start-icon, .track-end-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return tracksLayerGroup;
|
||||
}
|
||||
|
||||
export function updateTracksColors(tracksLayer) {
|
||||
const defaultColor = getTrackColor();
|
||||
|
||||
tracksLayer.eachLayer((trackGroup) => {
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({
|
||||
color: defaultColor,
|
||||
originalColor: defaultColor
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTracksOpacity(tracksLayer, opacity) {
|
||||
tracksLayer.eachLayer((trackGroup) => {
|
||||
trackGroup.eachLayer((layer) => {
|
||||
if (layer instanceof L.Polyline) {
|
||||
layer.setStyle({ opacity: opacity });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleTracksVisibility(tracksLayer, map, isVisible) {
|
||||
if (isVisible && !map.hasLayer(tracksLayer)) {
|
||||
tracksLayer.addTo(map);
|
||||
} else if (!isVisible && map.hasLayer(tracksLayer)) {
|
||||
map.removeLayer(tracksLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to filter tracks by criteria
|
||||
export function filterTracks(tracks, criteria) {
|
||||
return tracks.filter(track => {
|
||||
if (criteria.minDistance && track.distance < criteria.minDistance * 1000) return false;
|
||||
if (criteria.maxDistance && track.distance > criteria.maxDistance * 1000) return false;
|
||||
if (criteria.minDuration && track.duration < criteria.minDuration * 60) return false;
|
||||
if (criteria.maxDuration && track.duration > criteria.maxDuration * 60) return false;
|
||||
if (criteria.startDate && new Date(track.start_at) < new Date(criteria.startDate)) return false;
|
||||
if (criteria.endDate && new Date(track.end_at) > new Date(criteria.endDate)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
119
app/javascript/maps/tracks_README.md
Normal file
119
app/javascript/maps/tracks_README.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Tracks Map Layer
|
||||
|
||||
This module provides functionality for rendering tracks as a separate layer on Leaflet maps in Dawarich.
|
||||
|
||||
## Features
|
||||
|
||||
- **Distinct visual styling** - Tracks use brown color to differentiate from blue polylines
|
||||
- **Interactive hover/click** - Rich popups with track details including distance, duration, elevation
|
||||
- **Consistent styling** - All tracks use the same brown color for easy identification
|
||||
- **Layer management** - Integrates with Leaflet layer control
|
||||
- **Performance optimized** - Uses canvas rendering and efficient event handling
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Integration
|
||||
|
||||
The tracks layer is automatically integrated into the main maps controller:
|
||||
|
||||
```javascript
|
||||
// Import the tracks module
|
||||
import { createTracksLayer, updateTracksColors } from "../maps/tracks";
|
||||
|
||||
// Create tracks layer
|
||||
const tracksLayer = createTracksLayer(tracksData, map, userSettings, distanceUnit);
|
||||
|
||||
// Add to map
|
||||
tracksLayer.addTo(map);
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
All tracks use a consistent brown color (#8B4513) to ensure they are easily distinguishable from the blue polylines used for regular routes.
|
||||
|
||||
### Track Data Format
|
||||
|
||||
Tracks expect data in this format:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
start_at: "2025-01-15T10:00:00Z",
|
||||
end_at: "2025-01-15T11:30:00Z",
|
||||
distance: 15000, // meters
|
||||
duration: 5400, // seconds
|
||||
avg_speed: 25.5, // km/h
|
||||
elevation_gain: 200, // meters
|
||||
elevation_loss: 150, // meters
|
||||
elevation_max: 500, // meters
|
||||
elevation_min: 300, // meters
|
||||
original_path: "LINESTRING(-74.0060 40.7128, -74.0070 40.7130)", // PostGIS format
|
||||
// OR
|
||||
coordinates: [[40.7128, -74.0060], [40.7130, -74.0070]], // [lat, lng] array
|
||||
// OR
|
||||
path: [[40.7128, -74.0060], [40.7130, -74.0070]] // alternative coordinate format
|
||||
}
|
||||
```
|
||||
|
||||
### Coordinate Parsing
|
||||
|
||||
The module automatically handles different coordinate formats:
|
||||
|
||||
1. **Array format**: `track.coordinates` or `track.path` as `[[lat, lng], ...]`
|
||||
2. **PostGIS LineString**: Parses `"LINESTRING(lng lat, lng lat, ...)"` format
|
||||
3. **Fallback**: Creates simple line from start/end points if available
|
||||
|
||||
### API Integration
|
||||
|
||||
The tracks layer integrates with these API endpoints:
|
||||
|
||||
- **GET `/api/v1/tracks`** - Fetch existing tracks
|
||||
- **POST `/api/v1/tracks`** - Trigger track generation from points
|
||||
|
||||
### Settings Integration
|
||||
|
||||
Track settings are integrated into the main map settings panel:
|
||||
|
||||
- **Show Tracks** - Toggle track layer visibility
|
||||
- **Refresh Tracks** - Regenerate tracks from current points
|
||||
|
||||
### Layer Control
|
||||
|
||||
Tracks appear as "Tracks" in the Leaflet layer control, positioned above regular polylines with z-index 460.
|
||||
|
||||
## Visual Features
|
||||
|
||||
### Markers
|
||||
|
||||
- **Start marker**: 🚀 (rocket emoji)
|
||||
- **End marker**: 🎯 (target emoji)
|
||||
|
||||
### Popup Content
|
||||
|
||||
Track popups display:
|
||||
- Track ID
|
||||
- Start/end timestamps
|
||||
- Duration (formatted as days/hours/minutes)
|
||||
- Total distance
|
||||
- Average speed
|
||||
- Elevation statistics (gain/loss/max/min)
|
||||
|
||||
### Interaction States
|
||||
|
||||
- **Default**: Brown polylines (weight: 4)
|
||||
- **Hover**: Orange polylines (weight: 6)
|
||||
- **Clicked**: Red polylines (weight: 8, persistent until clicked elsewhere)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Uses Leaflet canvas renderer for efficient rendering
|
||||
- Custom pane (`tracksPane`) with z-index 460
|
||||
- Efficient coordinate parsing with error handling
|
||||
- Minimal DOM manipulation during interactions
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Graceful handling of missing coordinate data
|
||||
- Console warnings for unparseable track data
|
||||
- Fallback to empty layer if tracks API unavailable
|
||||
- Error messages for failed track generation
|
||||
36
app/jobs/tracks/create_job.rb
Normal file
36
app/jobs/tracks/create_job.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::CreateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find(user_id)
|
||||
tracks_created = Tracks::CreateFromPoints.new(user).call
|
||||
|
||||
create_success_notification(user, tracks_created)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Failed to create tracks for user')
|
||||
|
||||
create_error_notification(user, e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_success_notification(user, tracks_created)
|
||||
Notifications::Create.new(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: "Created #{tracks_created} tracks from your location data. Check your tracks section to view them."
|
||||
).call
|
||||
end
|
||||
|
||||
def create_error_notification(user, error)
|
||||
Notifications::Create.new(
|
||||
user: user,
|
||||
kind: :error,
|
||||
title: 'Track Generation Failed',
|
||||
content: "Failed to generate tracks from your location data: #{error.message}"
|
||||
).call
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,7 @@ class Point < ApplicationRecord
|
|||
belongs_to :visit, optional: true
|
||||
belongs_to :user
|
||||
belongs_to :country, optional: true
|
||||
belongs_to :track, optional: true
|
||||
|
||||
validates :timestamp, :lonlat, presence: true
|
||||
validates :lonlat, uniqueness: {
|
||||
|
|
|
|||
15
app/models/track.rb
Normal file
15
app/models/track.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Track < ApplicationRecord
|
||||
belongs_to :user
|
||||
has_many :points, dependent: :nullify
|
||||
|
||||
validates :start_at, :end_at, :original_path, presence: true
|
||||
validates :distance, :avg_speed, :duration, numericality: { greater_than: 0 }
|
||||
validates :elevation_gain, :elevation_loss, :elevation_max, :elevation_min,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
def calculate_path
|
||||
Tracks::BuildPath.new(points.pluck(:lonlat)).call
|
||||
end
|
||||
end
|
||||
|
|
@ -14,6 +14,7 @@ class User < ApplicationRecord
|
|||
has_many :points, through: :imports
|
||||
has_many :places, through: :visits
|
||||
has_many :trips, dependent: :destroy
|
||||
has_many :tracks, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
|
|
|
|||
130
app/services/tracks/README.md
Normal file
130
app/services/tracks/README.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Tracks Services
|
||||
|
||||
This directory contains services for working with tracks generated from user points.
|
||||
|
||||
## Tracks::CreateFromPoints
|
||||
|
||||
This service takes all points for a user and creates tracks by splitting them based on the user's configured settings for distance and time thresholds.
|
||||
|
||||
### Usage
|
||||
|
||||
```ruby
|
||||
# Basic usage
|
||||
user = User.find(123)
|
||||
service = Tracks::CreateFromPoints.new(user)
|
||||
tracks_created = service.call
|
||||
|
||||
puts "Created #{tracks_created} tracks for user #{user.email}"
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
The service:
|
||||
|
||||
1. **Fetches all user points** ordered by timestamp
|
||||
2. **Splits points into track segments** based on two thresholds:
|
||||
- **Distance threshold**: `user.safe_settings.meters_between_routes` (default: 500 meters)
|
||||
- **Time threshold**: `user.safe_settings.minutes_between_routes` (default: 30 minutes)
|
||||
3. **Creates Track records** with calculated statistics:
|
||||
- Distance (in meters)
|
||||
- Duration (in seconds)
|
||||
- Average speed (in km/h)
|
||||
- Elevation statistics (gain, loss, min, max)
|
||||
- PostGIS LineString path
|
||||
4. **Associates points with tracks** by updating the `track_id` field
|
||||
|
||||
### Track Splitting Logic
|
||||
|
||||
A new track is created when either condition is met:
|
||||
- **Time gap**: Time between consecutive points > time threshold
|
||||
- **Distance gap**: Distance between consecutive points > distance threshold
|
||||
|
||||
### Example with custom settings
|
||||
|
||||
```ruby
|
||||
# User with custom settings
|
||||
user.update!(settings: {
|
||||
'meters_between_routes' => 1000, # 1km distance threshold
|
||||
'minutes_between_routes' => 60 # 1 hour time threshold
|
||||
})
|
||||
|
||||
service = Tracks::CreateFromPoints.new(user)
|
||||
service.call
|
||||
```
|
||||
|
||||
### Background Job Usage
|
||||
|
||||
For large datasets, consider running in a background job:
|
||||
|
||||
```ruby
|
||||
class Tracks::CreateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find(user_id)
|
||||
tracks_created = Tracks::CreateFromPoints.new(user).call
|
||||
|
||||
# Create notification for user
|
||||
Notification.create!(
|
||||
user: user,
|
||||
title: 'Tracks Generated',
|
||||
content: "Created #{tracks_created} tracks from your location data",
|
||||
kind: :info
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Enqueue the job
|
||||
Tracks::CreateJob.perform_later(user.id)
|
||||
```
|
||||
|
||||
### Console Usage
|
||||
|
||||
```ruby
|
||||
# In Rails console
|
||||
rails console
|
||||
|
||||
# Generate tracks for a specific user
|
||||
user = User.find_by(email: 'user@example.com')
|
||||
Tracks::CreateFromPoints.new(user).call
|
||||
|
||||
# Generate tracks for all users
|
||||
User.find_each do |user|
|
||||
tracks_created = Tracks::CreateFromPoints.new(user).call
|
||||
puts "User #{user.id}: #{tracks_created} tracks created"
|
||||
end
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The service respects user settings:
|
||||
|
||||
- `meters_between_routes`: Maximum distance between points in the same track (meters)
|
||||
- `minutes_between_routes`: Maximum time between points in the same track (minutes)
|
||||
- `distance_unit`: Used for internal calculations (km/miles)
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Uses database transactions for consistency
|
||||
- Processes points with `find_each` to avoid loading all points into memory
|
||||
- Destroys existing tracks before regenerating (use with caution)
|
||||
- For users with many points, consider running as background job
|
||||
|
||||
### Track Statistics
|
||||
|
||||
Each track includes:
|
||||
|
||||
- **start_at/end_at**: First and last point timestamps
|
||||
- **distance**: Total distance in meters (converted from user's preferred unit)
|
||||
- **duration**: Total time in seconds
|
||||
- **avg_speed**: Average speed in km/h
|
||||
- **elevation_gain/loss**: Cumulative elevation changes
|
||||
- **elevation_min/max**: Altitude range
|
||||
- **original_path**: PostGIS LineString geometry
|
||||
|
||||
### Dependencies
|
||||
|
||||
- PostGIS for distance calculations and path geometry
|
||||
- Existing `Tracks::BuildPath` service for creating LineString geometry
|
||||
- User settings via `Users::SafeSettings`
|
||||
- Point model with `Distanceable` concern
|
||||
190
app/services/tracks/create_from_points.rb
Normal file
190
app/services/tracks/create_from_points.rb
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Tracks::CreateFromPoints
|
||||
attr_reader :user, :distance_threshold_meters, :time_threshold_minutes
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
@distance_threshold_meters = user.safe_settings.meters_between_routes || 500
|
||||
@time_threshold_minutes = user.safe_settings.minutes_between_routes || 30
|
||||
end
|
||||
|
||||
def call
|
||||
Rails.logger.info "Creating tracks for user #{user.id} with thresholds: #{distance_threshold_meters}m, #{time_threshold_minutes}min"
|
||||
|
||||
tracks_created = 0
|
||||
|
||||
Track.transaction do
|
||||
# Clear existing tracks for this user to regenerate them
|
||||
user.tracks.destroy_all
|
||||
|
||||
track_segments = split_points_into_tracks
|
||||
|
||||
track_segments.each do |segment_points|
|
||||
next if segment_points.size < 2
|
||||
|
||||
track = create_track_from_points(segment_points)
|
||||
tracks_created += 1 if track&.persisted?
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "Created #{tracks_created} tracks for user #{user.id}"
|
||||
tracks_created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_points
|
||||
@user_points ||= Point.where(user: user)
|
||||
.where.not(lonlat: nil)
|
||||
.where.not(timestamp: nil)
|
||||
.order(:timestamp)
|
||||
end
|
||||
|
||||
def split_points_into_tracks
|
||||
return [] if user_points.empty?
|
||||
|
||||
track_segments = []
|
||||
current_segment = []
|
||||
|
||||
user_points.find_each do |point|
|
||||
if should_start_new_track?(point, current_segment.last)
|
||||
# Finalize current segment if it has enough points
|
||||
track_segments << current_segment if current_segment.size >= 2
|
||||
current_segment = [point]
|
||||
else
|
||||
current_segment << point
|
||||
end
|
||||
end
|
||||
|
||||
# Don't forget the last segment
|
||||
track_segments << current_segment if current_segment.size >= 2
|
||||
|
||||
track_segments
|
||||
end
|
||||
|
||||
def should_start_new_track?(current_point, previous_point)
|
||||
return false if previous_point.nil?
|
||||
|
||||
# Check time threshold (convert minutes to seconds)
|
||||
time_diff_seconds = current_point.timestamp - previous_point.timestamp
|
||||
time_threshold_seconds = time_threshold_minutes.to_i * 60
|
||||
|
||||
return true if time_diff_seconds > time_threshold_seconds
|
||||
|
||||
# Check distance threshold
|
||||
distance_meters = calculate_distance_meters(previous_point, current_point)
|
||||
return true if distance_meters > distance_threshold_meters.to_i
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def calculate_distance_meters(point1, point2)
|
||||
# Use PostGIS to calculate distance in meters
|
||||
distance_query = <<-SQL.squish
|
||||
SELECT ST_Distance(
|
||||
ST_GeomFromEWKT($1)::geography,
|
||||
ST_GeomFromEWKT($2)::geography
|
||||
)
|
||||
SQL
|
||||
|
||||
Point.connection.select_value(distance_query, nil, [point1.lonlat, point2.lonlat]).to_f
|
||||
end
|
||||
|
||||
def create_track_from_points(points)
|
||||
track = Track.new(
|
||||
user_id: user.id,
|
||||
start_at: Time.zone.at(points.first.timestamp),
|
||||
end_at: Time.zone.at(points.last.timestamp),
|
||||
original_path: build_path(points)
|
||||
)
|
||||
|
||||
# Calculate track statistics
|
||||
track.distance = calculate_track_distance(points)
|
||||
track.duration = calculate_duration(points)
|
||||
track.avg_speed = calculate_average_speed(track.distance, track.duration)
|
||||
|
||||
# Calculate elevation statistics
|
||||
elevation_stats = calculate_elevation_stats(points)
|
||||
track.elevation_gain = elevation_stats[:gain]
|
||||
track.elevation_loss = elevation_stats[:loss]
|
||||
track.elevation_max = elevation_stats[:max]
|
||||
track.elevation_min = elevation_stats[:min]
|
||||
|
||||
if track.save
|
||||
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
|
||||
|
||||
track
|
||||
else
|
||||
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def build_path(points)
|
||||
Tracks::BuildPath.new(points.map(&:lonlat)).call
|
||||
end
|
||||
|
||||
def calculate_track_distance(points)
|
||||
# Use the existing total_distance method with user's preferred unit
|
||||
distance_in_user_unit = Point.total_distance(points, user.safe_settings.distance_unit || 'km')
|
||||
|
||||
# Convert to meters for storage (Track model expects distance in meters)
|
||||
case user.safe_settings.distance_unit
|
||||
when 'miles', 'mi'
|
||||
(distance_in_user_unit * 1609.344).round(2) # miles to meters
|
||||
else
|
||||
(distance_in_user_unit * 1000).round(2) # km to meters
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_duration(points)
|
||||
# Duration in seconds
|
||||
points.last.timestamp - points.first.timestamp
|
||||
end
|
||||
|
||||
def calculate_average_speed(distance_meters, duration_seconds)
|
||||
return 0.0 if duration_seconds <= 0 || distance_meters <= 0
|
||||
|
||||
# Speed in meters per second, then convert to km/h for storage
|
||||
speed_mps = distance_meters.to_f / duration_seconds
|
||||
(speed_mps * 3.6).round(2) # m/s to km/h
|
||||
end
|
||||
|
||||
def calculate_elevation_stats(points)
|
||||
altitudes = points.map(&:altitude).compact
|
||||
|
||||
return default_elevation_stats if altitudes.empty?
|
||||
|
||||
elevation_gain = 0
|
||||
elevation_loss = 0
|
||||
previous_altitude = altitudes.first
|
||||
|
||||
altitudes[1..].each do |altitude|
|
||||
diff = altitude - previous_altitude
|
||||
if diff > 0
|
||||
elevation_gain += diff
|
||||
else
|
||||
elevation_loss += diff.abs
|
||||
end
|
||||
previous_altitude = altitude
|
||||
end
|
||||
|
||||
{
|
||||
gain: elevation_gain.round,
|
||||
loss: elevation_loss.round,
|
||||
max: altitudes.max,
|
||||
min: altitudes.min
|
||||
}
|
||||
end
|
||||
|
||||
def default_elevation_stats
|
||||
{
|
||||
gain: 0,
|
||||
loss: 0,
|
||||
max: 0,
|
||||
min: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -111,6 +111,7 @@ class Users::SafeSettings
|
|||
end
|
||||
|
||||
def distance_unit
|
||||
# km or mi
|
||||
settings.dig('maps', 'distance_unit')
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
◀️
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
▶️
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
@ -44,17 +44,17 @@
|
|||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Today",
|
||||
map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn btn-neutral hover:btn-ghost" %>
|
||||
class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,8 +67,9 @@
|
|||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.settings.to_json.html_safe %>'
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>'
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@
|
|||
<p>Enable or disable visits suggestions. It's a background task that runs every day at midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.</p>
|
||||
<div class="card-actions justify-end">
|
||||
<% if current_user.safe_settings.visits_suggestions_enabled? %>
|
||||
<%= link_to 'Disable', settings_path(settings: { 'visits_suggestions_enabled' => false }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-error' %>
|
||||
<%= link_to 'Disable', settings_path(settings: { 'visits_suggestions_enabled' => 'false' }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-error' %>
|
||||
<% else %>
|
||||
<%= link_to 'Enable', settings_path(settings: { 'visits_suggestions_enabled' => true }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-success' %>
|
||||
<%= link_to 'Enable', settings_path(settings: { 'visits_suggestions_enabled' => 'true' }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-success' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ Rails.application.routes.draw do
|
|||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[index create update destroy]
|
||||
resources :tracks, only: :index
|
||||
resources :visits, only: %i[index update] do
|
||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||
collection do
|
||||
|
|
|
|||
19
db/migrate/20250703193656_create_tracks.rb
Normal file
19
db/migrate/20250703193656_create_tracks.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
class CreateTracks < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :tracks do |t|
|
||||
t.datetime :start_at, null: false
|
||||
t.datetime :end_at, null: false
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.line_string :original_path, null: false
|
||||
t.float :distance
|
||||
t.float :avg_speed
|
||||
t.integer :duration
|
||||
t.integer :elevation_gain
|
||||
t.integer :elevation_loss
|
||||
t.integer :elevation_max
|
||||
t.integer :elevation_min
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250703193657_add_track_id_to_points.rb
Normal file
9
db/migrate/20250703193657_add_track_id_to_points.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddTrackIdToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_reference :points, :track, index: { algorithm: :concurrently }
|
||||
end
|
||||
end
|
||||
22
db/schema.rb
generated
22
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_07_03_193657) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -181,6 +181,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
t.string "external_track_id"
|
||||
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
|
||||
t.bigint "country_id"
|
||||
t.bigint "track_id"
|
||||
t.index ["altitude"], name: "index_points_on_altitude"
|
||||
t.index ["battery"], name: "index_points_on_battery"
|
||||
t.index ["battery_status"], name: "index_points_on_battery_status"
|
||||
|
|
@ -196,6 +197,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
|
||||
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
||||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||
t.index ["track_id"], name: "index_points_on_track_id"
|
||||
t.index ["trigger"], name: "index_points_on_trigger"
|
||||
t.index ["user_id"], name: "index_points_on_user_id"
|
||||
t.index ["visit_id"], name: "index_points_on_visit_id"
|
||||
|
|
@ -216,6 +218,23 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
t.index ["year"], name: "index_stats_on_year"
|
||||
end
|
||||
|
||||
create_table "tracks", force: :cascade do |t|
|
||||
t.datetime "start_at", null: false
|
||||
t.datetime "end_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false
|
||||
t.float "distance"
|
||||
t.float "avg_speed"
|
||||
t.integer "duration"
|
||||
t.integer "elevation_gain"
|
||||
t.integer "elevation_loss"
|
||||
t.integer "elevation_max"
|
||||
t.integer "elevation_min"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_tracks_on_user_id"
|
||||
end
|
||||
|
||||
create_table "trips", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.datetime "started_at", null: false
|
||||
|
|
@ -280,6 +299,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_184017) do
|
|||
add_foreign_key "points", "users"
|
||||
add_foreign_key "points", "visits"
|
||||
add_foreign_key "stats", "users"
|
||||
add_foreign_key "tracks", "users"
|
||||
add_foreign_key "trips", "users"
|
||||
add_foreign_key "visits", "areas"
|
||||
add_foreign_key "visits", "places"
|
||||
|
|
|
|||
15
spec/factories/tracks.rb
Normal file
15
spec/factories/tracks.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FactoryBot.define do
|
||||
factory :track do
|
||||
association :user
|
||||
start_at { 1.hour.ago }
|
||||
end_at { 30.minutes.ago }
|
||||
original_path { 'LINESTRING(-74.0060 40.7128, -74.0070 40.7130)' }
|
||||
distance { 1500.0 } # in meters
|
||||
avg_speed { 25.0 } # in km/h
|
||||
duration { 1800 } # 30 minutes in seconds
|
||||
elevation_gain { 50 }
|
||||
elevation_loss { 20 }
|
||||
elevation_max { 100 }
|
||||
elevation_min { 50 }
|
||||
end
|
||||
end
|
||||
|
|
@ -14,11 +14,11 @@ FactoryBot.define do
|
|||
settings do
|
||||
{
|
||||
'route_opacity' => '0.5',
|
||||
'meters_between_routes' => '100',
|
||||
'minutes_between_routes' => '100',
|
||||
'meters_between_routes' => '500',
|
||||
'minutes_between_routes' => '30',
|
||||
'fog_of_war_meters' => '100',
|
||||
'time_threshold_minutes' => '100',
|
||||
'merge_threshold_minutes' => '100',
|
||||
'time_threshold_minutes' => '30',
|
||||
'merge_threshold_minutes' => '15',
|
||||
'maps' => {
|
||||
'distance_unit' => 'km'
|
||||
}
|
||||
|
|
|
|||
80
spec/jobs/tracks/create_job_spec.rb
Normal file
80
spec/jobs/tracks/create_job_spec.rb
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::CreateJob, type: :job do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#perform' do
|
||||
it 'calls the service and creates a notification' do
|
||||
service_instance = instance_double(Tracks::CreateFromPoints)
|
||||
allow(Tracks::CreateFromPoints).to receive(:new).with(user).and_return(service_instance)
|
||||
allow(service_instance).to receive(:call).and_return(3)
|
||||
|
||||
notification_service = instance_double(Notifications::Create)
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(Tracks::CreateFromPoints).to have_received(:new).with(user)
|
||||
expect(service_instance).to have_received(:call)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 3 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
expect(notification_service).to have_received(:call)
|
||||
end
|
||||
|
||||
context 'when service raises an error' do
|
||||
let(:error_message) { 'Something went wrong' }
|
||||
|
||||
before do
|
||||
service_instance = instance_double(Tracks::CreateFromPoints)
|
||||
allow(Tracks::CreateFromPoints).to receive(:new).with(user).and_return(service_instance)
|
||||
allow(service_instance).to receive(:call).and_raise(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'creates an error notification' do
|
||||
notification_service = instance_double(Notifications::Create)
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :error,
|
||||
title: 'Track Generation Failed',
|
||||
content: "Failed to generate tracks from your location data: #{error_message}"
|
||||
)
|
||||
expect(notification_service).to have_received(:call)
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
allow(Rails.logger).to receive(:error)
|
||||
allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil))
|
||||
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with("Failed to create tracks for user #{user.id}: #{error_message}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'raises ActiveRecord::RecordNotFound' do
|
||||
expect {
|
||||
described_class.new.perform(999)
|
||||
}.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'is queued on default queue' do
|
||||
expect(described_class.new.queue_name).to eq('default')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,7 @@ RSpec.describe Point, type: :model do
|
|||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to belong_to(:country).optional }
|
||||
it { is_expected.to belong_to(:visit).optional }
|
||||
it { is_expected.to belong_to(:track).optional }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
|
|
|
|||
21
spec/models/track_spec.rb
Normal file
21
spec/models/track_spec.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Track, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to have_many(:points).dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:start_at) }
|
||||
it { is_expected.to validate_presence_of(:end_at) }
|
||||
it { is_expected.to validate_presence_of(:original_path) }
|
||||
it { is_expected.to validate_numericality_of(:distance).is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:avg_speed).is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:duration).is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:elevation_gain).is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:elevation_loss).is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:elevation_max).is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:elevation_min).is_greater_than(0) }
|
||||
end
|
||||
end
|
||||
|
|
@ -14,6 +14,7 @@ RSpec.describe User, type: :model do
|
|||
it { is_expected.to have_many(:visits).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:places).through(:visits) }
|
||||
it { is_expected.to have_many(:trips).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:tracks).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
|
|
|
|||
7
spec/requests/api/v1/tracks_spec.rb
Normal file
7
spec/requests/api/v1/tracks_spec.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe "Api::V1::Tracks", type: :request do
|
||||
describe "GET /index" do
|
||||
pending "add some examples (or delete) #{__FILE__}"
|
||||
end
|
||||
end
|
||||
294
spec/services/tracks/create_from_points_spec.rb
Normal file
294
spec/services/tracks/create_from_points_spec.rb
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::CreateFromPoints do
|
||||
let(:user) { create(:user) }
|
||||
let(:service) { described_class.new(user) }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets user and thresholds from user settings' do
|
||||
expect(service.user).to eq(user)
|
||||
expect(service.distance_threshold_meters).to eq(user.safe_settings.meters_between_routes)
|
||||
expect(service.time_threshold_minutes).to eq(user.safe_settings.minutes_between_routes)
|
||||
end
|
||||
|
||||
context 'with custom user settings' do
|
||||
before do
|
||||
user.update!(settings: user.settings.merge({
|
||||
'meters_between_routes' => 1000,
|
||||
'minutes_between_routes' => 60
|
||||
}))
|
||||
end
|
||||
|
||||
it 'uses custom settings' do
|
||||
service = described_class.new(user)
|
||||
expect(service.distance_threshold_meters).to eq(1000)
|
||||
expect(service.time_threshold_minutes).to eq(60)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with no points' do
|
||||
it 'returns 0 tracks created' do
|
||||
expect(service.call).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with insufficient points' do
|
||||
let!(:single_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
||||
|
||||
it 'returns 0 tracks created' do
|
||||
expect(service.call).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points that form a single track' do
|
||||
let(:base_time) { 1.hour.ago }
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: base_time.to_i,
|
||||
lonlat: 'POINT(-74.0060 40.7128)', altitude: 10),
|
||||
create(:point, user: user, timestamp: (base_time + 5.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0070 40.7130)', altitude: 15),
|
||||
create(:point, user: user, timestamp: (base_time + 10.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0080 40.7132)', altitude: 20)
|
||||
]
|
||||
end
|
||||
|
||||
it 'creates one track' do
|
||||
expect { service.call }.to change(Track, :count).by(1)
|
||||
end
|
||||
|
||||
it 'returns 1 track created' do
|
||||
expect(service.call).to eq(1)
|
||||
end
|
||||
|
||||
it 'sets track attributes correctly' do
|
||||
service.call
|
||||
track = Track.last
|
||||
|
||||
expect(track.user).to eq(user)
|
||||
expect(track.start_at).to be_within(1.second).of(base_time)
|
||||
expect(track.end_at).to be_within(1.second).of(base_time + 10.minutes)
|
||||
expect(track.duration).to eq(600) # 10 minutes in seconds
|
||||
expect(track.original_path).to be_present
|
||||
expect(track.distance).to be > 0
|
||||
expect(track.avg_speed).to be > 0
|
||||
expect(track.elevation_gain).to eq(10) # 20 - 10
|
||||
expect(track.elevation_loss).to eq(0)
|
||||
expect(track.elevation_max).to eq(20)
|
||||
expect(track.elevation_min).to eq(10)
|
||||
end
|
||||
|
||||
it 'associates points with the track' do
|
||||
service.call
|
||||
track = Track.last
|
||||
expect(points.map(&:reload).map(&:track)).to all(eq(track))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points that should be split by time' do
|
||||
let(:base_time) { 2.hours.ago }
|
||||
let!(:points) do
|
||||
[
|
||||
# First track
|
||||
create(:point, user: user, timestamp: base_time.to_i,
|
||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
||||
create(:point, user: user, timestamp: (base_time + 5.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0070 40.7130)'),
|
||||
|
||||
# Gap > time threshold (default 30 minutes)
|
||||
create(:point, user: user, timestamp: (base_time + 45.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0080 40.7132)'),
|
||||
create(:point, user: user, timestamp: (base_time + 50.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0090 40.7134)')
|
||||
]
|
||||
end
|
||||
|
||||
it 'creates two tracks' do
|
||||
expect { service.call }.to change(Track, :count).by(2)
|
||||
end
|
||||
|
||||
it 'returns 2 tracks created' do
|
||||
expect(service.call).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points that should be split by distance' do
|
||||
let(:base_time) { 1.hour.ago }
|
||||
let!(:points) do
|
||||
[
|
||||
# First track - close points
|
||||
create(:point, user: user, timestamp: base_time.to_i,
|
||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
||||
create(:point, user: user, timestamp: (base_time + 1.minute).to_i,
|
||||
lonlat: 'POINT(-74.0061 40.7129)'),
|
||||
|
||||
# Far point (> distance threshold, but within time threshold)
|
||||
create(:point, user: user, timestamp: (base_time + 2.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0500 40.7500)'), # ~5km away
|
||||
create(:point, user: user, timestamp: (base_time + 3.minutes).to_i,
|
||||
lonlat: 'POINT(-74.0501 40.7501)')
|
||||
]
|
||||
end
|
||||
|
||||
it 'creates two tracks' do
|
||||
expect { service.call }.to change(Track, :count).by(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with existing tracks' do
|
||||
let!(:existing_track) { create(:track, user: user) }
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
||||
lonlat: 'POINT(-74.0070 40.7130)')
|
||||
]
|
||||
end
|
||||
|
||||
it 'destroys existing tracks and creates new ones' do
|
||||
expect { service.call }.to change(Track, :count).by(0) # -1 + 1
|
||||
expect(Track.exists?(existing_track.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed elevation data' do
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
||||
lonlat: 'POINT(-74.0060 40.7128)', altitude: 100),
|
||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
||||
lonlat: 'POINT(-74.0070 40.7130)', altitude: 150),
|
||||
create(:point, user: user, timestamp: 40.minutes.ago.to_i,
|
||||
lonlat: 'POINT(-74.0080 40.7132)', altitude: 120)
|
||||
]
|
||||
end
|
||||
|
||||
it 'calculates elevation correctly' do
|
||||
service.call
|
||||
track = Track.last
|
||||
|
||||
expect(track.elevation_gain).to eq(50) # 150 - 100
|
||||
expect(track.elevation_loss).to eq(30) # 150 - 120
|
||||
expect(track.elevation_max).to eq(150)
|
||||
expect(track.elevation_min).to eq(100)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points missing altitude data' do
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
||||
lonlat: 'POINT(-74.0060 40.7128)', altitude: nil),
|
||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
||||
lonlat: 'POINT(-74.0070 40.7130)', altitude: nil)
|
||||
]
|
||||
end
|
||||
|
||||
it 'uses default elevation values' do
|
||||
service.call
|
||||
track = Track.last
|
||||
|
||||
expect(track.elevation_gain).to eq(0)
|
||||
expect(track.elevation_loss).to eq(0)
|
||||
expect(track.elevation_max).to eq(0)
|
||||
expect(track.elevation_min).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#should_start_new_track?' do
|
||||
let(:point1) { build(:point, timestamp: 1.hour.ago.to_i, lonlat: 'POINT(-74.0060 40.7128)') }
|
||||
let(:point2) { build(:point, timestamp: 50.minutes.ago.to_i, lonlat: 'POINT(-74.0070 40.7130)') }
|
||||
|
||||
it 'returns false when previous point is nil' do
|
||||
result = service.send(:should_start_new_track?, point1, nil)
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns true when time threshold is exceeded' do
|
||||
# Create a point > 30 minutes later (default threshold)
|
||||
later_point = build(:point, timestamp: 29.minutes.ago.to_i, lonlat: 'POINT(-74.0070 40.7130)')
|
||||
|
||||
result = service.send(:should_start_new_track?, later_point, point1)
|
||||
expect(result).to be true
|
||||
end
|
||||
|
||||
it 'returns true when distance threshold is exceeded' do
|
||||
# Create a point far away (> 500m default threshold)
|
||||
far_point = build(:point, timestamp: 59.minutes.ago.to_i, lonlat: 'POINT(-74.0500 40.7500)')
|
||||
|
||||
result = service.send(:should_start_new_track?, far_point, point1)
|
||||
expect(result).to be true
|
||||
end
|
||||
|
||||
it 'returns false when both thresholds are not exceeded' do
|
||||
result = service.send(:should_start_new_track?, point2, point1)
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_distance_meters' do
|
||||
let(:point1) { build(:point, lonlat: 'POINT(-74.0060 40.7128)') }
|
||||
let(:point2) { build(:point, lonlat: 'POINT(-74.0070 40.7130)') }
|
||||
|
||||
it 'calculates distance between two points in meters' do
|
||||
distance = service.send(:calculate_distance_meters, point1, point2)
|
||||
expect(distance).to be > 0
|
||||
expect(distance).to be < 200 # Should be small distance for close points
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_average_speed' do
|
||||
it 'calculates speed correctly' do
|
||||
# 1000 meters in 100 seconds = 10 m/s = 36 km/h
|
||||
speed = service.send(:calculate_average_speed, 1000, 100)
|
||||
expect(speed).to eq(36.0)
|
||||
end
|
||||
|
||||
it 'returns 0 for zero duration' do
|
||||
speed = service.send(:calculate_average_speed, 1000, 0)
|
||||
expect(speed).to eq(0.0)
|
||||
end
|
||||
|
||||
it 'returns 0 for zero distance' do
|
||||
speed = service.send(:calculate_average_speed, 0, 100)
|
||||
expect(speed).to eq(0.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_track_distance' do
|
||||
let(:points) do
|
||||
[
|
||||
build(:point, lonlat: 'POINT(-74.0060 40.7128)'),
|
||||
build(:point, lonlat: 'POINT(-74.0070 40.7130)')
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Point).to receive(:total_distance).and_return(1.5) # 1.5 km
|
||||
end
|
||||
|
||||
it 'converts km to meters by default' do
|
||||
distance = service.send(:calculate_track_distance, points)
|
||||
expect(distance).to eq(1500.0) # 1.5 km = 1500 meters
|
||||
end
|
||||
|
||||
context 'with miles unit' do
|
||||
before do
|
||||
user.update!(settings: user.settings.merge({'maps' => {'distance_unit' => 'miles'}}))
|
||||
end
|
||||
|
||||
it 'converts miles to meters' do
|
||||
distance = service.send(:calculate_track_distance, points)
|
||||
expect(distance).to eq(2414.02) # 1.5 miles ≈ 2414 meters
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue