Add tracks calculation and storage in the database

This commit is contained in:
Eugene Burmakin 2025-07-03 20:18:18 +02:00
parent fd4b785a19
commit 862f601e1d
29 changed files with 1710 additions and 24 deletions

View file

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

View 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

View file

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

View file

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

View file

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

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

View 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

View 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

View file

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

View file

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

View 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

View 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

View file

@ -111,6 +111,7 @@ class Users::SafeSettings
end
def distance_unit
# km or mi
settings.dig('maps', 'distance_unit')
end

View file

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

View file

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

View file

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

View 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

View 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
View file

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

View file

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

View 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

View file

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

View file

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

View 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

View 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