Fix remembering family members layer state and refreshing locations

This commit is contained in:
Eugene Burmakin 2025-10-21 19:54:25 +02:00
parent 07216e00dd
commit 05237995cf
7 changed files with 173 additions and 68 deletions

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,8 @@ class Family::InvitationsController < ApplicationController
end end
def show def show
@invitation = Family::Invitation.find_by!(token: params[:token]) token = params[:token] || params[:id]
@invitation = Family::Invitation.find_by!(token: token)
if @invitation.expired? if @invitation.expired?
redirect_to root_path, alert: 'This invitation has expired.' and return redirect_to root_path, alert: 'This invitation has expired.' and return

View file

@ -341,6 +341,11 @@ export default class extends Controller {
mapsController.updateLayerControl({ mapsController.updateLayerControl({
"Family Members": this.familyMarkersLayer "Family Members": this.familyMarkersLayer
}); });
// Dispatch event to notify that Family Members layer is now available
document.dispatchEvent(new CustomEvent('family:layer:ready', {
detail: { layer: this.familyMarkersLayer }
}));
} }
setupEventListeners() { setupEventListeners() {

View file

@ -101,6 +101,9 @@ export default class extends BaseController {
this.speedColoredPolylines = this.userSettings.speed_colored_routes || false; this.speedColoredPolylines = this.userSettings.speed_colored_routes || false;
this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback); this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback);
// Flag to prevent saving layers during initialization/restoration
this.isRestoringLayers = false;
// Ensure we have valid markers array // Ensure we have valid markers array
if (!Array.isArray(this.markers)) { if (!Array.isArray(this.markers)) {
console.warn('Markers is not an array, setting to empty array'); console.warn('Markers is not an array, setting to empty array');
@ -229,6 +232,9 @@ export default class extends BaseController {
// Initialize layers based on settings // Initialize layers based on settings
this.initializeLayersFromSettings(); this.initializeLayersFromSettings();
// Listen for Family Members layer becoming ready
this.setupFamilyLayerListener();
// Initialize tracks layer // Initialize tracks layer
this.initializeTracksLayer(); this.initializeTracksLayer();
@ -465,8 +471,10 @@ 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 // Save enabled layers whenever a layer is added (unless we're restoring from settings)
this.saveEnabledLayers(); if (!this.isRestoringLayers) {
this.saveEnabledLayers();
}
if (event.name === 'Routes') { if (event.name === 'Routes') {
this.handleRouteLayerToggle('routes'); this.handleRouteLayerToggle('routes');
@ -523,8 +531,10 @@ export default class extends BaseController {
}); });
this.map.on('overlayremove', (event) => { this.map.on('overlayremove', (event) => {
// Save enabled layers whenever a layer is removed // Save enabled layers whenever a layer is removed (unless we're restoring from settings)
this.saveEnabledLayers(); if (!this.isRestoringLayers) {
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
@ -585,7 +595,8 @@ export default class extends BaseController {
const enabledLayers = []; const enabledLayers = [];
const layerNames = [ const layerNames = [
'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War', 'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War',
'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits' 'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits',
'Family Members'
]; ];
const controlsLayer = { const controlsLayer = {
@ -598,7 +609,8 @@ export default class extends BaseController {
'Areas': this.areasLayer, 'Areas': this.areasLayer,
'Photos': this.photoMarkers, 'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer() 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Family Members': window.familyMembersController?.familyMarkersLayer
}; };
layerNames.forEach(name => { layerNames.forEach(name => {
@ -608,16 +620,6 @@ export default class extends BaseController {
} }
}); });
// 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', { fetch('/api/v1/settings', {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
@ -1481,23 +1483,31 @@ export default class extends BaseController {
'Areas': this.areasLayer, 'Areas': this.areasLayer,
'Photos': this.photoMarkers, 'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer() 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Family Members': window.familyMembersController?.familyMarkersLayer
}; };
// Add family member layers if available
if (window.familyController && window.familyController.familyLayers) {
Object.entries(window.familyController.familyLayers).forEach(([memberName, layer]) => {
controlsLayer[memberName] = layer;
});
}
// Apply saved layer preferences // Apply saved layer preferences
Object.entries(controlsLayer).forEach(([name, layer]) => { Object.entries(controlsLayer).forEach(([name, layer]) => {
if (!layer) return; if (!layer) {
if (enabledLayers.includes(name)) {
console.log(`Layer ${name} is in enabled layers but layer object is null/undefined`);
}
return;
}
const shouldBeEnabled = enabledLayers.includes(name); const shouldBeEnabled = enabledLayers.includes(name);
const isCurrentlyEnabled = this.map.hasLayer(layer); const isCurrentlyEnabled = this.map.hasLayer(layer);
if (name === 'Family Members') {
console.log('Family Members layer check:', {
shouldBeEnabled,
isCurrentlyEnabled,
layerExists: !!layer,
controllerExists: !!window.familyMembersController
});
}
if (shouldBeEnabled && !isCurrentlyEnabled) { if (shouldBeEnabled && !isCurrentlyEnabled) {
// Add layer to map // Add layer to map
layer.addTo(this.map); layer.addTo(this.map);
@ -1534,6 +1544,11 @@ export default class extends BaseController {
if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) {
this.map.addControl(this.drawControl); this.map.addControl(this.drawControl);
} }
} else if (name === 'Family Members') {
// Refresh family locations when layer is restored
if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') {
window.familyMembersController.refreshFamilyLocations();
}
} }
} else if (!shouldBeEnabled && isCurrentlyEnabled) { } else if (!shouldBeEnabled && isCurrentlyEnabled) {
// Remove layer from map // Remove layer from map
@ -1543,6 +1558,36 @@ export default class extends BaseController {
}); });
} }
setupFamilyLayerListener() {
// Listen for when the Family Members layer becomes available
document.addEventListener('family:layer:ready', (event) => {
console.log('Family layer ready event received');
const enabledLayers = this.userSettings.enabled_map_layers || [];
// Check if Family Members should be enabled based on saved settings
if (enabledLayers.includes('Family Members')) {
const layer = event.detail.layer;
if (layer && !this.map.hasLayer(layer)) {
// Set flag to prevent saving during restoration
this.isRestoringLayers = true;
layer.addTo(this.map);
console.log('Enabled layer: Family Members (from ready event)');
// Refresh family locations
if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') {
window.familyMembersController.refreshFamilyLocations();
}
// Reset flag after a short delay to allow all events to complete
setTimeout(() => {
this.isRestoringLayers = false;
}, 100);
}
}
}, { once: true }); // Only listen once
}
toggleRightPanel() { toggleRightPanel() {
if (this.rightPanel) { if (this.rightPanel) {
const panel = document.querySelector('.leaflet-right-panel'); const panel = document.querySelector('.leaflet-right-panel');

View file

@ -125,32 +125,41 @@ export function showFlashMessage(type, message) {
if (!flashContainer) { if (!flashContainer) {
flashContainer = document.createElement('div'); flashContainer = document.createElement('div');
flashContainer.id = 'flash-messages'; flashContainer.id = 'flash-messages';
// Use z-[9999] to ensure flash messages appear above navbar (z-50) flashContainer.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50';
flashContainer.className = 'fixed top-20 right-5 flex flex-col-reverse gap-2';
flashContainer.style.zIndex = '9999';
document.body.appendChild(flashContainer); document.body.appendChild(flashContainer);
} }
// Create the flash message div // Create the flash message div with DaisyUI alert classes
const flashDiv = document.createElement('div'); const flashDiv = document.createElement('div');
flashDiv.setAttribute('data-controller', 'removals'); flashDiv.setAttribute('data-controller', 'removals');
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg shadow-lg`; flashDiv.setAttribute('data-removals-timeout-value', type === 'notice' || type === 'success' ? '5000' : '0');
flashDiv.setAttribute('role', 'alert');
flashDiv.className = `alert ${getAlertClass(type)} shadow-lg z-[6000]`;
// Create the message div // Create the content wrapper
const messageDiv = document.createElement('div'); const contentDiv = document.createElement('div');
messageDiv.className = 'mr-4'; contentDiv.className = 'flex items-center gap-2';
messageDiv.innerText = message;
// Add the icon
const icon = getFlashIcon(type);
contentDiv.appendChild(icon);
// Create the message span
const messageSpan = document.createElement('span');
messageSpan.innerText = message;
contentDiv.appendChild(messageSpan);
// Create the close button // Create the close button
const closeButton = document.createElement('button'); const closeButton = document.createElement('button');
closeButton.setAttribute('type', 'button'); closeButton.setAttribute('type', 'button');
closeButton.setAttribute('data-action', 'click->removals#remove'); closeButton.setAttribute('data-action', 'click->removals#remove');
closeButton.className = 'ml-auto'; // Ensures button stays on the right closeButton.setAttribute('aria-label', 'Close');
closeButton.className = 'btn btn-sm btn-circle btn-ghost';
// Create the SVG icon for the close button // Create the SVG icon for the close button
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
closeIcon.setAttribute('class', 'h-6 w-6'); closeIcon.setAttribute('class', 'h-5 w-5');
closeIcon.setAttribute('fill', 'none'); closeIcon.setAttribute('fill', 'none');
closeIcon.setAttribute('viewBox', '0 0 24 24'); closeIcon.setAttribute('viewBox', '0 0 24 24');
closeIcon.setAttribute('stroke', 'currentColor'); closeIcon.setAttribute('stroke', 'currentColor');
@ -164,33 +173,75 @@ export function showFlashMessage(type, message) {
// Append all elements // Append all elements
closeIcon.appendChild(closeIconPath); closeIcon.appendChild(closeIconPath);
closeButton.appendChild(closeIcon); closeButton.appendChild(closeIcon);
flashDiv.appendChild(messageDiv); flashDiv.appendChild(contentDiv);
flashDiv.appendChild(closeButton); flashDiv.appendChild(closeButton);
flashContainer.appendChild(flashDiv); flashContainer.appendChild(flashDiv);
// Automatically remove after 5 seconds // Automatically remove after 5 seconds for notice/success
setTimeout(() => { if (type === 'notice' || type === 'success') {
if (flashDiv && flashDiv.parentNode) { setTimeout(() => {
flashDiv.remove(); if (flashDiv && flashDiv.parentNode) {
// Remove container if empty flashDiv.remove();
if (flashContainer && !flashContainer.hasChildNodes()) { // Remove container if empty
flashContainer.remove(); if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
} }
} }, 5000);
}, 5000); }
} }
function classesForFlash(type) { function getAlertClass(type) {
switch (type) { switch (type) {
case 'error': case 'error':
return 'bg-red-100 text-red-700 border-red-300'; case 'alert':
return 'alert-error';
case 'notice': case 'notice':
return 'bg-blue-100 text-blue-700 border-blue-300'; case 'info':
return 'alert-info';
case 'success':
return 'alert-success';
case 'warning':
return 'alert-warning';
default: default:
return 'bg-blue-100 text-blue-700 border-blue-300'; return 'alert-info';
} }
} }
function getFlashIcon(type) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('class', 'h-6 w-6 shrink-0 stroke-current');
svg.setAttribute('fill', 'none');
svg.setAttribute('viewBox', '0 0 24 24');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
path.setAttribute('stroke-width', '2');
switch (type) {
case 'error':
case 'alert':
path.setAttribute('d', 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z');
break;
case 'success':
path.setAttribute('d', 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z');
break;
case 'warning':
path.setAttribute('d', 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z');
break;
case 'notice':
case 'info':
default:
path.setAttribute('d', 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z');
break;
}
svg.appendChild(path);
return svg;
}
export function debounce(func, wait) { export function debounce(func, wait) {
let timeout; let timeout;
return function executedFunction(...args) { return function executedFunction(...args) {

View file

@ -17,6 +17,9 @@ class Users::ImportDataJob < ApplicationJob
import_stats = Users::ImportData.new(user, archive_path).import import_stats = Users::ImportData.new(user, archive_path).import
# Reset counter caches after import completes
User.reset_counters(user.id, :points)
Rails.logger.info "Import completed successfully for user #{user.email}: #{import_stats}" Rails.logger.info "Import completed successfully for user #{user.email}: #{import_stats}"
rescue ActiveRecord::RecordNotFound => e rescue ActiveRecord::RecordNotFound => e
ExceptionReporter.call(e, "Import job failed for import_id #{import_id} - import not found") ExceptionReporter.call(e, "Import job failed for import_id #{import_id} - import not found")

View file

@ -14,8 +14,8 @@
You've been invited by <strong class="text-base-content"><%= @invitation.invited_by.email %></strong> to join their family. Create your account to accept the invitation and start sharing location data. You've been invited by <strong class="text-base-content"><%= @invitation.invited_by.email %></strong> to join their family. Create your account to accept the invitation and start sharing location data.
</p> </p>
<div class="alert alert-info inline-flex rounded-lg px-4 py-2 mt-4"> <div class="alert alert-info inline-flex items-center rounded-lg px-4 py-3 mt-4 gap-3">
<%= icon 'info', class: "h-5 w-5 mr-2" %> <%= icon 'info', class: "h-5 w-5 shrink-0" %>
<span class="text-sm font-medium"> <span class="text-sm font-medium">
Your email (<%= @invitation.email %>) will be used for this account Your email (<%= @invitation.email %>) will be used for this account
</span> </span>
@ -96,23 +96,23 @@
<!-- Invitation Details --> <!-- Invitation Details -->
<div class="bg-base-300 rounded-lg p-6 mb-6"> <div class="bg-base-300 rounded-lg p-6 mb-6">
<h3 class="text-sm font-medium text-base-content opacity-70 mb-3">Invitation Details</h3> <h3 class="text-lg font-semibold text-base-content mb-6">Invitation Details</h3>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div class="space-y-2">
<span class="text-base-content opacity-60">Family:</span> <div class="text-sm text-base-content opacity-60">Family:</div>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.family.name %></span> <div class="text-base font-semibold text-base-content"><%= @invitation.family.name %></div>
</div> </div>
<div> <div class="space-y-2">
<span class="text-base-content opacity-60">Invited by:</span> <div class="text-sm text-base-content opacity-60">Invited by:</div>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.invited_by.email %></span> <div class="text-base font-semibold text-base-content"><%= @invitation.invited_by.email %></div>
</div> </div>
<div> <div class="space-y-2">
<span class="text-base-content opacity-60">Your email:</span> <div class="text-sm text-base-content opacity-60">Your email:</div>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.email %></span> <div class="text-base font-semibold text-base-content"><%= @invitation.email %></div>
</div> </div>
<div> <div class="space-y-2">
<span class="text-base-content opacity-60">Expires:</span> <div class="text-sm text-base-content opacity-60">Expires:</div>
<span class="ml-2 font-semibold text-base-content"><%= @invitation.expires_at.strftime('%b %d, %Y') %></span> <div class="text-base font-semibold text-base-content"><%= @invitation.expires_at.strftime('%b %d, %Y') %></div>
</div> </div>
</div> </div>
</div> </div>