Merge branch 'dev' into fix/family-stuff

This commit is contained in:
Eugene Burmakin 2025-10-20 20:21:20 +02:00
commit 1bf02bc063
17 changed files with 400 additions and 87 deletions

View file

@ -18,10 +18,12 @@ In this release we're introducing family features that allow users to create fam
- Sign out button works again. #1844 - Sign out button works again. #1844
- Fixed user deletion bug where user could not be deleted due to counter cache on points. - Fixed user deletion bug where user could not be deleted due to counter cache on points.
- Users always have default distance unit set to kilometers. #1832
## Changed ## Changed
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840 - Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
- Importing process for Google Maps Timeline exports, GeoJSON and geodata from photos is now significantly faster.
# [0.33.1] - 2025-10-07 # [0.33.1] - 2025-10-07

File diff suppressed because one or more lines are too long

View file

@ -30,7 +30,8 @@ class Api::V1::SettingsController < ApiController
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:preferred_map_layer, :points_rendering_mode, :live_map_enabled, :preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold :speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
enabled_map_layers: []
) )
end end
end end

View file

@ -239,7 +239,7 @@ export default class extends Controller {
} }
createTooltipContent(lastSeen) { createTooltipContent(lastSeen) {
return `Last updated: ${lastSeen}`; return `Last seen: ${lastSeen}`;
} }
createPopupContent(location, lastSeen) { createPopupContent(location, lastSeen) {
@ -264,7 +264,7 @@ export default class extends Controller {
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)} ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
</p> </p>
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};"> <p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
<strong>Last updated:</strong> ${lastSeen} <strong>Last seen:</strong> ${lastSeen}
</p> </p>
</div> </div>
`; `;
@ -483,4 +483,4 @@ export default class extends Controller {
getFamilyMemberCount() { getFamilyMemberCount() {
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0; return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
} }
} }

View file

@ -465,6 +465,9 @@ export default class extends BaseController {
// Add event listeners for overlay layer changes to keep routes/tracks selector in sync // Add event listeners for overlay layer changes to keep routes/tracks selector in sync
this.map.on('overlayadd', (event) => { this.map.on('overlayadd', (event) => {
// Save enabled layers whenever a layer is added
this.saveEnabledLayers();
if (event.name === 'Routes') { if (event.name === 'Routes') {
this.handleRouteLayerToggle('routes'); this.handleRouteLayerToggle('routes');
// Re-establish event handlers when routes are manually added // Re-establish event handlers when routes are manually added
@ -520,6 +523,9 @@ export default class extends BaseController {
}); });
this.map.on('overlayremove', (event) => { this.map.on('overlayremove', (event) => {
// Save enabled layers whenever a layer is removed
this.saveEnabledLayers();
if (event.name === 'Routes' || event.name === 'Tracks') { if (event.name === 'Routes' || event.name === 'Tracks') {
// Don't auto-switch when layers are manually turned off // Don't auto-switch when layers are manually turned off
// Just update the radio button state to reflect current visibility // Just update the radio button state to reflect current visibility
@ -553,9 +559,12 @@ export default class extends BaseController {
} }
updatePreferredBaseLayer(selectedLayerName) { updatePreferredBaseLayer(selectedLayerName) {
fetch(`/api/v1/settings?api_key=${this.apiKey}`, { fetch('/api/v1/settings', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({ body: JSON.stringify({
settings: { settings: {
preferred_map_layer: selectedLayerName preferred_map_layer: selectedLayerName
@ -572,6 +581,71 @@ export default class extends BaseController {
}); });
} }
saveEnabledLayers() {
const enabledLayers = [];
const layerNames = [
'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War',
'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits'
];
const controlsLayer = {
'Points': this.markersLayer,
'Routes': this.polylinesLayer,
'Tracks': this.tracksLayer,
'Heatmap': this.heatmapLayer,
'Fog of War': this.fogOverlay,
'Scratch map': this.scratchLayerManager?.getLayer(),
'Areas': this.areasLayer,
'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer()
};
layerNames.forEach(name => {
const layer = controlsLayer[name];
if (layer && this.map.hasLayer(layer)) {
enabledLayers.push(name);
}
});
// Add family member layers
if (window.familyController && window.familyController.familyLayers) {
Object.keys(window.familyController.familyLayers).forEach(memberName => {
const layer = window.familyController.familyLayers[memberName];
if (layer && this.map.hasLayer(layer)) {
enabledLayers.push(memberName);
}
});
}
fetch('/api/v1/settings', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({
settings: {
enabled_map_layers: enabledLayers
},
}),
})
.then((response) => response.json())
.then((data) => {
if (data.status === 'success') {
console.log('Enabled layers saved:', enabledLayers);
showFlashMessage('notice', 'Map layer preferences saved');
} else {
console.error('Failed to save enabled layers:', data.message);
showFlashMessage('error', `Failed to save layer preferences: ${data.message}`);
}
})
.catch(error => {
console.error('Error saving enabled layers:', error);
showFlashMessage('error', 'Error saving layer preferences');
});
}
deletePoint(id, apiKey) { deletePoint(id, apiKey) {
fetch(`/api/v1/points/${id}`, { fetch(`/api/v1/points/${id}`, {
method: 'DELETE', method: 'DELETE',
@ -1011,9 +1085,12 @@ export default class extends BaseController {
const opacityValue = event.target.route_opacity.value.replace('%', ''); const opacityValue = event.target.route_opacity.value.replace('%', '');
const decimalOpacity = parseFloat(opacityValue) / 100; const decimalOpacity = parseFloat(opacityValue) / 100;
fetch(`/api/v1/settings?api_key=${this.apiKey}`, { fetch('/api/v1/settings', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({ body: JSON.stringify({
settings: { settings: {
route_opacity: decimalOpacity.toString(), route_opacity: decimalOpacity.toString(),
@ -1385,45 +1462,80 @@ export default class extends BaseController {
// Initialize layer visibility based on user settings or defaults // Initialize layer visibility based on user settings or defaults
// This method sets up the initial state of overlay layers // This method sets up the initial state of overlay layers
// Note: Don't automatically add layers to map here - let the layer control and user preferences handle it // Get enabled layers from user settings
// The layer control will manage which layers are visible based on user interaction const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap'];
console.log('Initializing layers from settings:', enabledLayers);
// Initialize photos layer if user wants it visible const controlsLayer = {
if (this.userSettings.photos_enabled) { 'Points': this.markersLayer,
console.log('Photos layer enabled via user settings'); 'Routes': this.polylinesLayer,
const urlParams = new URLSearchParams(window.location.search); 'Tracks': this.tracksLayer,
const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 'Heatmap': this.heatmapLayer,
const endDate = urlParams.get('end_at') || new Date().toISOString(); 'Fog of War': this.fogOverlay,
'Scratch map': this.scratchLayerManager?.getLayer(),
'Areas': this.areasLayer,
'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer()
};
console.log('Auto-fetching photos for date range:', { startDate, endDate }); // Add family member layers if available
fetchAndDisplayPhotos({ if (window.familyController && window.familyController.familyLayers) {
map: this.map, Object.entries(window.familyController.familyLayers).forEach(([memberName, layer]) => {
photoMarkers: this.photoMarkers, controlsLayer[memberName] = layer;
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
}); });
} }
// Initialize fog of war if enabled in settings // Apply saved layer preferences
if (this.userSettings.fog_of_war_enabled) { Object.entries(controlsLayer).forEach(([name, layer]) => {
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); if (!layer) return;
}
// Initialize visits manager functionality const shouldBeEnabled = enabledLayers.includes(name);
// Check if any visits layers are enabled by default and load data const isCurrentlyEnabled = this.map.hasLayer(layer);
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
// Check if confirmed visits layer is enabled by default (it's added to map in constructor)
const confirmedVisitsEnabled = this.map.hasLayer(this.visitsManager.getConfirmedVisitCirclesLayer());
console.log('Visits initialization - confirmedVisitsEnabled:', confirmedVisitsEnabled); if (shouldBeEnabled && !isCurrentlyEnabled) {
// Add layer to map
layer.addTo(this.map);
console.log(`Enabled layer: ${name}`);
if (confirmedVisitsEnabled) { // Trigger special initialization for certain layers
console.log('Confirmed visits layer enabled by default - fetching visits data'); if (name === 'Photos') {
this.visitsManager.fetchAndDisplayVisits(); const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const endDate = urlParams.get('end_at') || new Date().toISOString();
fetchAndDisplayPhotos({
map: this.map,
photoMarkers: this.photoMarkers,
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
});
} else if (name === 'Fog of War') {
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
} else if (name === 'Suggested Visits' || name === 'Confirmed Visits') {
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
this.visitsManager.fetchAndDisplayVisits();
}
} else if (name === 'Scratch map') {
if (this.scratchLayerManager) {
this.scratchLayerManager.addToMap();
}
} else if (name === 'Routes') {
// Re-establish event handlers for routes layer
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
} else if (name === 'Areas') {
// Show draw control when Areas layer is enabled
if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) {
this.map.addControl(this.drawControl);
}
}
} else if (!shouldBeEnabled && isCurrentlyEnabled) {
// Remove layer from map
this.map.removeLayer(layer);
console.log(`Disabled layer: ${name}`);
} }
} });
} }
toggleRightPanel() { toggleRightPanel() {

View file

@ -5,6 +5,7 @@ class Geojson::Importer
include Imports::FileLoader include Imports::FileLoader
include PointValidation include PointValidation
BATCH_SIZE = 1000
attr_reader :import, :user_id, :file_path attr_reader :import, :user_id, :file_path
def initialize(import, user_id, file_path = nil) def initialize(import, user_id, file_path = nil)
@ -17,13 +18,46 @@ class Geojson::Importer
json = load_json_data json = load_json_data
data = Geojson::Params.new(json).call data = Geojson::Params.new(json).call
data.each.with_index(1) do |point, index| points_data = data.map do |point|
next if point[:lonlat].nil? next if point[:lonlat].nil?
next if point_exists?(point, user_id)
Point.create!(point.merge(user_id:, import_id: import.id)) point.merge(
user_id: user_id,
import_id: import.id,
created_at: Time.current,
updated_at: Time.current
)
end
broadcast_import_progress(import, index) points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
bulk_insert_points(batch)
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
end end
end end
private
def bulk_insert_points(batch)
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
unique_batch,
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
# rubocop:enable Rails/SkipsModelValidations
rescue StandardError => e
create_notification("Failed to process GeoJSON batch: #{e.message}")
end
def create_notification(message)
Notification.create!(
user_id: user_id,
title: 'GeoJSON Import Error',
content: message,
kind: :error
)
end
end end

View file

@ -12,30 +12,23 @@ class GoogleMaps::PhoneTakeoutImporter
@file_path = file_path @file_path = file_path
end end
BATCH_SIZE = 1000
def call def call
points_data = parse_json points_data = parse_json.compact.map do |point_data|
point_data.merge(
points_data.compact.each.with_index(1) do |point_data, index| import_id: import.id,
next if Point.exists?( topic: 'Google Maps Phone Timeline Export',
timestamp: point_data[:timestamp],
lonlat: point_data[:lonlat],
user_id:
)
Point.create(
lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
accuracy: point_data[:accuracy],
altitude: point_data[:altitude],
velocity: point_data[:velocity],
import_id: import.id,
topic: 'Google Maps Phone Timeline Export',
tracker_id: 'google-maps-phone-timeline-export', tracker_id: 'google-maps-phone-timeline-export',
user_id: user_id: user_id,
created_at: Time.current,
updated_at: Time.current
) )
end
broadcast_import_progress(import, index) points_data.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
bulk_insert_points(batch)
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
end end
end end
@ -177,4 +170,28 @@ class GoogleMaps::PhoneTakeoutImporter
point_hash(lat, lon, timestamp, segment) point_hash(lat, lon, timestamp, segment)
end end
end end
def bulk_insert_points(batch)
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
unique_batch,
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
# rubocop:enable Rails/SkipsModelValidations
rescue StandardError => e
create_notification("Failed to process phone takeout batch: #{e.message}")
end
def create_notification(message)
Notification.create!(
user_id: user_id,
title: 'Google Maps Phone Takeout Import Error',
content: message,
kind: :error
)
end
end end

View file

@ -32,6 +32,10 @@ class GoogleMaps::RecordsImporter
timestamp: parse_timestamp(location), timestamp: parse_timestamp(location),
altitude: location['altitude'], altitude: location['altitude'],
velocity: location['velocity'], velocity: location['velocity'],
accuracy: location['accuracy'],
vertical_accuracy: location['verticalAccuracy'],
course: location['heading'],
battery: parse_battery_charging(location['batteryCharging']),
raw_data: location, raw_data: location,
topic: 'Google Maps Timeline Export', topic: 'Google Maps Timeline Export',
tracker_id: 'google-maps-timeline-export', tracker_id: 'google-maps-timeline-export',
@ -74,6 +78,12 @@ class GoogleMaps::RecordsImporter
) )
end end
def parse_battery_charging(battery_charging)
return nil if battery_charging.nil?
battery_charging ? 1 : 0
end
def create_notification(message) def create_notification(message)
Notification.create!( Notification.create!(
user: @import.user, user: @import.user,

View file

@ -43,6 +43,7 @@ class GoogleMaps::SemanticHistoryImporter
{ {
lonlat: point_data[:lonlat], lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
accuracy: point_data[:accuracy],
raw_data: point_data[:raw_data], raw_data: point_data[:raw_data],
topic: 'Google Maps Timeline Export', topic: 'Google Maps Timeline Export',
tracker_id: 'google-maps-timeline-export', tracker_id: 'google-maps-timeline-export',
@ -86,6 +87,7 @@ class GoogleMaps::SemanticHistoryImporter
longitude: activity['startLocation']['longitudeE7'], longitude: activity['startLocation']['longitudeE7'],
latitude: activity['startLocation']['latitudeE7'], latitude: activity['startLocation']['latitudeE7'],
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'], timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
accuracy: activity.dig('startLocation', 'accuracyMetres'),
raw_data: activity raw_data: activity
) )
end end
@ -111,6 +113,7 @@ class GoogleMaps::SemanticHistoryImporter
longitude: place_visit['location']['longitudeE7'], longitude: place_visit['location']['longitudeE7'],
latitude: place_visit['location']['latitudeE7'], latitude: place_visit['location']['latitudeE7'],
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'], timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
accuracy: place_visit.dig('location', 'accuracyMetres'),
raw_data: place_visit raw_data: place_visit
) )
elsif (candidate = place_visit.dig('otherCandidateLocations', 0)) elsif (candidate = place_visit.dig('otherCandidateLocations', 0))
@ -125,14 +128,16 @@ class GoogleMaps::SemanticHistoryImporter
longitude: candidate['longitudeE7'], longitude: candidate['longitudeE7'],
latitude: candidate['latitudeE7'], latitude: candidate['latitudeE7'],
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'], timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
accuracy: candidate['accuracyMetres'],
raw_data: place_visit raw_data: place_visit
) )
end end
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:) def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:, accuracy: nil)
{ {
lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})", lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})",
timestamp: Timestamps.parse_timestamp(timestamp), timestamp: Timestamps.parse_timestamp(timestamp),
accuracy: accuracy,
raw_data: raw_data raw_data: raw_data
} }
end end

View file

@ -4,6 +4,8 @@ class Photos::Importer
include Imports::Broadcaster include Imports::Broadcaster
include Imports::FileLoader include Imports::FileLoader
include PointValidation include PointValidation
BATCH_SIZE = 1000
attr_reader :import, :user_id, :file_path attr_reader :import, :user_id, :file_path
def initialize(import, user_id, file_path = nil) def initialize(import, user_id, file_path = nil)
@ -14,25 +16,54 @@ class Photos::Importer
def call def call
json = load_json_data json = load_json_data
points_data = json.map { |point| prepare_point_data(point) }
json.each.with_index(1) { |point, index| create_point(point, index) } points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
bulk_insert_points(batch)
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
end
end end
def create_point(point, index) private
return 0 unless valid?(point)
return 0 if point_exists?(point, point['timestamp'])
Point.create( def prepare_point_data(point)
lonlat: point['lonlat'], return nil unless valid?(point)
{
lonlat: point['lonlat'],
longitude: point['longitude'], longitude: point['longitude'],
latitude: point['latitude'], latitude: point['latitude'],
timestamp: point['timestamp'].to_i, timestamp: point['timestamp'].to_i,
raw_data: point, raw_data: point,
import_id: import.id, import_id: import.id,
user_id: user_id: user_id,
) created_at: Time.current,
updated_at: Time.current
}
end
broadcast_import_progress(import, index) def bulk_insert_points(batch)
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
unique_batch,
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
# rubocop:enable Rails/SkipsModelValidations
rescue StandardError => e
create_notification("Failed to process photo location batch: #{e.message}")
end
def create_notification(message)
Notification.create!(
user_id: user_id,
title: 'Photos Import Error',
content: message,
kind: :error
)
end end
def valid?(point) def valid?(point)

View file

@ -19,7 +19,8 @@ class Users::SafeSettings
'photoprism_url' => nil, 'photoprism_url' => nil,
'photoprism_api_key' => nil, 'photoprism_api_key' => nil,
'maps' => { 'distance_unit' => 'km' }, 'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true' 'visits_suggestions_enabled' => 'true',
'enabled_map_layers' => ['Routes', 'Heatmap']
}.freeze }.freeze
def initialize(settings = {}) def initialize(settings = {})
@ -47,7 +48,8 @@ class Users::SafeSettings
distance_unit: distance_unit, distance_unit: distance_unit,
visits_suggestions_enabled: visits_suggestions_enabled?, visits_suggestions_enabled: visits_suggestions_enabled?,
speed_color_scale: speed_color_scale, speed_color_scale: speed_color_scale,
fog_of_war_threshold: fog_of_war_threshold fog_of_war_threshold: fog_of_war_threshold,
enabled_map_layers: enabled_map_layers
} }
end end
# rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/MethodLength
@ -127,4 +129,8 @@ class Users::SafeSettings
def fog_of_war_threshold def fog_of_war_threshold
settings['fog_of_war_threshold'] settings['fog_of_war_threshold']
end end
def enabled_map_layers
settings['enabled_map_layers']
end
end end

View file

@ -82,7 +82,7 @@
data-points-target="map" data-points-target="map"
data-api_key="<%= current_user.api_key %>" data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>" data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>' data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
data-user_theme="<%= current_user&.theme || 'dark' %>" data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>' data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>' data-tracks='<%= @tracks.to_json.html_safe %>'

View file

@ -18,7 +18,7 @@
class="w-full h-full rounded-lg" class="w-full h-full rounded-lg"
data-trips-target="container" data-trips-target="container"
data-api_key="<%= current_user.api_key %>" data-api_key="<%= current_user.api_key %>"
data-user_settings="<%= current_user.settings.to_json %>" data-user_settings="<%= current_user.safe_settings.settings.to_json %>"
data-path="<%= trip.path.to_json %>" data-path="<%= trip.path.to_json %>"
data-started_at="<%= trip.started_at %>" data-started_at="<%= trip.started_at %>"
data-ended_at="<%= trip.ended_at %>" data-ended_at="<%= trip.ended_at %>"

View file

@ -5,7 +5,7 @@
data-controller="trips" data-controller="trips"
data-trips-target="container" data-trips-target="container"
data-api_key="<%= trip.user.api_key %>" data-api_key="<%= trip.user.api_key %>"
data-user_settings="<%= trip.user.settings.to_json %>" data-user_settings="<%= trip.user.safe_settings.settings.to_json %>"
data-path="<%= trip.path.coordinates.to_json %>" data-path="<%= trip.path.coordinates.to_json %>"
data-started_at="<%= trip.started_at %>" data-started_at="<%= trip.started_at %>"
data-ended_at="<%= trip.ended_at %>" data-ended_at="<%= trip.ended_at %>"

View file

@ -15,7 +15,7 @@
data-trip-map-trip-id-value="<%= trip.id %>" data-trip-map-trip-id-value="<%= trip.id %>"
data-trip-map-path-value="<%= trip.path.coordinates.to_json %>" data-trip-map-path-value="<%= trip.path.coordinates.to_json %>"
data-trip-map-api-key-value="<%= current_user.api_key %>" data-trip-map-api-key-value="<%= current_user.api_key %>"
data-trip-map-user-settings-value="<%= current_user.settings.to_json %>" data-trip-map-user-settings-value="<%= current_user.safe_settings.settings.to_json %>"
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>"> data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>">
</div> </div>
</div> </div>

View file

@ -17,6 +17,12 @@ RSpec.describe GoogleMaps::RecordsImporter do
'accuracy' => 10, 'accuracy' => 10,
'altitude' => 100, 'altitude' => 100,
'verticalAccuracy' => 5, 'verticalAccuracy' => 5,
'heading' => 270,
'velocity' => 15,
'batteryCharging' => true,
'source' => 'GPS',
'deviceTag' => 1234567890,
'platformType' => 'ANDROID',
'activity' => [ 'activity' => [
{ {
'timestampMs' => (time.to_f * 1000).to_i.to_s, 'timestampMs' => (time.to_f * 1000).to_i.to_s,
@ -111,5 +117,87 @@ RSpec.describe GoogleMaps::RecordsImporter do
expect(created_point.timestamp).to eq(time.to_i) expect(created_point.timestamp).to eq(time.to_i)
end end
end end
context 'with additional Records.json schema fields' do
let(:locations) do
[
{
'timestamp' => time.iso8601,
'latitudeE7' => 123_456_789,
'longitudeE7' => 123_456_789,
'accuracy' => 20,
'altitude' => 150,
'verticalAccuracy' => 10,
'heading' => 270,
'velocity' => 10,
'batteryCharging' => true,
'source' => 'WIFI',
'deviceTag' => 1234567890,
'platformType' => 'ANDROID'
}
]
end
it 'extracts all supported fields' do
expect { parser }.to change(Point, :count).by(1)
created_point = Point.last
expect(created_point.accuracy).to eq(20)
expect(created_point.altitude).to eq(150)
expect(created_point.vertical_accuracy).to eq(10)
expect(created_point.course).to eq(270)
expect(created_point.velocity).to eq('10')
expect(created_point.battery).to eq(1) # true -> 1
end
it 'stores all fields in raw_data' do
parser
created_point = Point.last
expect(created_point.raw_data['source']).to eq('WIFI')
expect(created_point.raw_data['deviceTag']).to eq(1234567890)
expect(created_point.raw_data['platformType']).to eq('ANDROID')
end
end
context 'with batteryCharging false' do
let(:locations) do
[
{
'timestamp' => time.iso8601,
'latitudeE7' => 123_456_789,
'longitudeE7' => 123_456_789,
'batteryCharging' => false
}
]
end
it 'stores battery as 0' do
expect { parser }.to change(Point, :count).by(1)
expect(Point.last.battery).to eq(0)
end
end
context 'with missing optional fields' do
let(:locations) do
[
{
'timestamp' => time.iso8601,
'latitudeE7' => 123_456_789,
'longitudeE7' => 123_456_789
}
]
end
it 'handles missing fields gracefully' do
expect { parser }.to change(Point, :count).by(1)
created_point = Point.last
expect(created_point.accuracy).to be_nil
expect(created_point.vertical_accuracy).to be_nil
expect(created_point.course).to be_nil
expect(created_point.battery).to be_nil
end
end
end end
end end

View file

@ -29,7 +29,8 @@ RSpec.describe Users::SafeSettings do
distance_unit: 'km', distance_unit: 'km',
visits_suggestions_enabled: true, visits_suggestions_enabled: true,
speed_color_scale: nil, speed_color_scale: nil,
fog_of_war_threshold: nil fog_of_war_threshold: nil,
enabled_map_layers: ['Routes', 'Heatmap']
} }
) )
end end
@ -53,7 +54,8 @@ RSpec.describe Users::SafeSettings do
'photoprism_url' => 'https://photoprism.example.com', 'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key', 'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }, 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false 'visits_suggestions_enabled' => false,
'enabled_map_layers' => ['Points', 'Routes', 'Areas', 'Photos']
} }
end end
let(:safe_settings) { described_class.new(settings) } let(:safe_settings) { described_class.new(settings) }
@ -76,7 +78,8 @@ RSpec.describe Users::SafeSettings do
"photoprism_url" => "https://photoprism.example.com", "photoprism_url" => "https://photoprism.example.com",
"photoprism_api_key" => "photoprism-key", "photoprism_api_key" => "photoprism-key",
"maps" => { "name" => "custom", "url" => "https://custom.example.com" }, "maps" => { "name" => "custom", "url" => "https://custom.example.com" },
"visits_suggestions_enabled" => false "visits_suggestions_enabled" => false,
"enabled_map_layers" => ['Points', 'Routes', 'Areas', 'Photos']
} }
) )
end end
@ -102,7 +105,8 @@ RSpec.describe Users::SafeSettings do
distance_unit: nil, distance_unit: nil,
visits_suggestions_enabled: false, visits_suggestions_enabled: false,
speed_color_scale: nil, speed_color_scale: nil,
fog_of_war_threshold: nil fog_of_war_threshold: nil,
enabled_map_layers: ['Points', 'Routes', 'Areas', 'Photos']
} }
) )
end end
@ -132,6 +136,7 @@ RSpec.describe Users::SafeSettings do
expect(safe_settings.photoprism_api_key).to be_nil expect(safe_settings.photoprism_api_key).to be_nil
expect(safe_settings.maps).to eq({ "distance_unit" => "km" }) expect(safe_settings.maps).to eq({ "distance_unit" => "km" })
expect(safe_settings.visits_suggestions_enabled?).to be true expect(safe_settings.visits_suggestions_enabled?).to be true
expect(safe_settings.enabled_map_layers).to eq(['Routes', 'Heatmap'])
end end
end end
@ -153,7 +158,8 @@ RSpec.describe Users::SafeSettings do
'photoprism_url' => 'https://photoprism.example.com', 'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key', 'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }, 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false 'visits_suggestions_enabled' => false,
'enabled_map_layers' => ['Points', 'Tracks', 'Fog of War', 'Suggested Visits']
} }
end end
@ -174,6 +180,7 @@ RSpec.describe Users::SafeSettings do
expect(safe_settings.photoprism_api_key).to eq('photoprism-key') expect(safe_settings.photoprism_api_key).to eq('photoprism-key')
expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' }) expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' })
expect(safe_settings.visits_suggestions_enabled?).to be false expect(safe_settings.visits_suggestions_enabled?).to be false
expect(safe_settings.enabled_map_layers).to eq(['Points', 'Tracks', 'Fog of War', 'Suggested Visits'])
end end
end end
end end