feat(find-training): Add viewport sync and marker hover interactions

- Add viewport sync: sidebar shows only trainers visible in map area
- Add mouseover event on markers showing info window on hover
- Set optimized:false on markers for reliable hover events
- Add legacy URL redirects (/find-a-trainer → /find-training)
- Remove deprecated find-a-trainer page from Page Manager
- Update Status.md with session changes
- Bump version to 2.2.4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ben 2026-02-01 11:42:45 -04:00
parent 21c908af81
commit 19147d978e
6 changed files with 301 additions and 49 deletions

View file

@ -1,14 +1,57 @@
# HVAC Community Events - Project Status
**Last Updated:** February 1, 2026
**Current Session:** Find Training Page Implementation - Complete
**Version:** 2.2.0 (Ready for Staging Deployment)
**Current Session:** Find Training Page Enhancements - Complete
**Version:** 2.2.4 (Deployed to Production)
---
## 🎯 CURRENT SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026)
## 🎯 CURRENT SESSION - FIND TRAINING PAGE ENHANCEMENTS (Feb 1, 2026)
### Status: ✅ **COMPLETE - Ready for Staging Deployment & E2E Testing**
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Improve Find Training page UX with viewport sync and marker hover interactions.
### Changes Made
1. ✅ **Viewport Sync** - Sidebar now shows only trainers visible in current map area
- Added `visibleTrainers` array to track filtered trainers
- Added `syncSidebarWithViewport()` method filtering by map bounds
- Map `idle` event triggers sync on pan/zoom
- Count shows "X of Y trainers" when zoomed in
2. ✅ **Marker Hover Interaction** - Info window appears on hover
- Added `mouseover` event listener to trainer/venue markers
- Set `optimized: false` on markers for reliable hover events
- Hover shows info window preview with "View Profile" button
- Click on "View Profile" opens full modal with contact form
3. ✅ **Legacy URL Redirects**
- `/find-a-trainer/``/find-training/` (301 redirect)
- `/find-trainer/``/find-training/` (301 redirect)
- Removed old page from Page Manager
### Files Modified
| File | Change |
|------|--------|
| `assets/js/find-training-map.js` | Added viewport sync, hover events, optimized:false |
| `assets/js/find-training-filters.js` | Updated filter handler for visibleTrainers |
| `includes/class-hvac-route-manager.php` | Added legacy URL redirects |
| `includes/class-hvac-page-manager.php` | Removed find-a-trainer page definition |
| `includes/class-hvac-plugin.php` | Version bumped to 2.2.4 |
### Verified Behavior
- ✅ Hover over marker → Info window appears immediately
- ✅ Click "View Profile" → Full modal with trainer details + contact form
- ✅ Pan/zoom map → Sidebar updates to show visible trainers only
- ✅ Legacy URLs redirect to new page
---
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Replace the buggy MapGeo-based `/find-a-trainer` page with a new `/find-training` page built from scratch using Google Maps JavaScript API.
@ -69,13 +112,12 @@ Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found an
- ✅ Auto-geocoding for new venues
- ✅ Rate-limited batch geocoding for existing venues
### Next Steps
1. ⏳ Deploy to staging: `./scripts/deploy.sh staging`
2. ⏳ Run E2E tests on Find Training page
3. ⏳ Verify map loads with markers
4. ⏳ Test filters and geolocation
5. ⏳ Verify contact form sends email
6. ⏳ Deploy to production after validation
### Deployment Status
- ✅ Deployed to staging
- ✅ Map loads with markers and clustering
- ✅ Filters working (state, certification, format)
- ✅ Contact form functional
- ✅ Deployed to production
---

View file

@ -36,6 +36,7 @@
*/
init: function() {
this.bindEvents();
this.initMobileFilterToggle();
},
/**
@ -133,12 +134,11 @@
// Update map data
HVACTrainingMap.trainers = response.data.trainers || [];
HVACTrainingMap.venues = response.data.venues || [];
HVACTrainingMap.visibleTrainers = HVACTrainingMap.trainers.slice(); // Reset to all
HVACTrainingMap.updateMarkers();
HVACTrainingMap.updateCounts(
response.data.total_trainers,
response.data.total_venues
);
HVACTrainingMap.updateCounts(HVACTrainingMap.trainers.length);
HVACTrainingMap.updateTrainerGrid();
// Note: syncSidebarWithViewport will be called by map 'idle' event
}
},
complete: function() {
@ -375,6 +375,66 @@
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Initialize mobile filter toggle
*/
initMobileFilterToggle: function() {
const self = this;
// Mobile filter panel toggle
$(document).on('click', '.hvac-mobile-filter-toggle', function() {
const $toggle = $(this);
const $panel = $('#hvac-mobile-filter-panel');
const isExpanded = $toggle.attr('aria-expanded') === 'true';
if (isExpanded) {
$panel.attr('hidden', '');
$toggle.attr('aria-expanded', 'false');
} else {
$panel.removeAttr('hidden');
$toggle.attr('aria-expanded', 'true');
}
});
// Sync mobile filter selects with desktop selects
$('#hvac-filter-state-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-state').val(value);
self.activeFilters.state = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
$('#hvac-filter-certification-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-certification').val(value);
self.activeFilters.certification = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
$('#hvac-filter-format-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-format').val(value);
self.activeFilters.training_format = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Also sync desktop to mobile when desktop changes
$('#hvac-filter-state').on('change', function() {
$('#hvac-filter-state-mobile').val($(this).val());
});
$('#hvac-filter-certification').on('change', function() {
$('#hvac-filter-certification-mobile').val($(this).val());
});
$('#hvac-filter-format').on('change', function() {
$('#hvac-filter-format-mobile').val($(this).val());
});
}
};

View file

@ -31,6 +31,9 @@
trainers: [],
venues: [],
// Visible trainers (filtered by map bounds)
visibleTrainers: [],
// Configuration
config: {
mapElementId: 'hvac-training-map',
@ -45,21 +48,29 @@
init: function() {
const self = this;
// Check if API key is configured
if (typeof hvacFindTraining === 'undefined' || !hvacFindTraining.api_key_configured) {
console.warn('Google Maps API key not configured');
// Still load trainer directory data
this.loadTrainerDirectory();
return;
}
// Check if Google Maps is loaded
if (typeof google === 'undefined' || typeof google.maps === 'undefined') {
console.error('Google Maps API not loaded');
this.showMapError('Google Maps failed to load. Please refresh the page.');
// Still load trainer directory data
this.loadTrainerDirectory();
return;
}
// Override config with localized data
if (typeof hvacFindTraining !== 'undefined') {
if (hvacFindTraining.map_center) {
this.config.defaultCenter = hvacFindTraining.map_center;
}
if (hvacFindTraining.default_zoom) {
this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom);
}
if (hvacFindTraining.map_center) {
this.config.defaultCenter = hvacFindTraining.map_center;
}
if (hvacFindTraining.default_zoom) {
this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom);
}
// Create the map
@ -73,6 +84,36 @@
// Bind events
this.bindEvents();
// Initialize responsive features
this.handleWindowResize();
this.initSidebarToggle();
},
/**
* Load just the trainer directory (no map)
*/
loadTrainerDirectory: function() {
const self = this;
$.ajax({
url: hvacFindTraining.ajax_url,
type: 'POST',
data: {
action: 'hvac_get_training_map_data',
nonce: hvacFindTraining.nonce
},
success: function(response) {
if (response.success && response.data) {
self.trainers = response.data.trainers || [];
self.updateTrainerGrid(self.trainers);
self.updateCounts(self.trainers.length, 0);
}
},
error: function() {
self.hideLoading();
}
});
},
/**
@ -110,6 +151,12 @@
this.map.addListener('click', () => {
this.infoWindow.close();
});
// Sync sidebar with map viewport on pan/zoom
const self = this;
this.map.addListener('idle', function() {
self.syncSidebarWithViewport();
});
},
/**
@ -157,10 +204,13 @@
if (response.success) {
self.trainers = response.data.trainers || [];
self.venues = response.data.venues || [];
self.visibleTrainers = self.trainers.slice(); // Initially all trainers are "visible"
self.updateMarkers();
self.updateCounts(response.data.total_trainers, response.data.total_venues);
self.updateCounts(self.trainers.length);
self.updateTrainerGrid();
// Note: syncSidebarWithViewport will be called by map 'idle' event
// to filter trainers to current viewport
} else {
self.showMapError(response.data?.message || 'Failed to load data');
}
@ -222,14 +272,19 @@
map: this.map,
title: trainer.name,
icon: this.getTrainerIcon(),
optimized: true
optimized: false // Required for reliable hover events
});
// Store trainer data on marker
marker.trainerData = trainer;
marker.markerType = 'trainer';
// Add click listener
// Add hover listener to show info window preview
marker.addListener('mouseover', function() {
self.showTrainerInfoWindow(this);
});
// Add click listener (also shows info window, for touch devices)
marker.addListener('click', function() {
self.showTrainerInfoWindow(this);
});
@ -249,14 +304,19 @@
map: this.map,
title: venue.name,
icon: this.getVenueIcon(),
optimized: true
optimized: false // Required for reliable hover events
});
// Store venue data on marker
marker.venueData = venue;
marker.markerType = 'venue';
// Add click listener
// Add hover listener to show info window preview
marker.addListener('mouseover', function() {
self.showVenueInfoWindow(this);
});
// Add click listener (also shows info window, for touch devices)
marker.addListener('click', function() {
self.showVenueInfoWindow(this);
});
@ -585,21 +645,30 @@
const $grid = $('#hvac-trainer-grid');
$grid.empty();
if (this.trainers.length === 0) {
$grid.html('<div class="hvac-no-results"><p>No trainers found matching your criteria.</p></div>');
// Use visible trainers if available (viewport sync), otherwise use all trainers
const trainersToShow = this.visibleTrainers.length > 0 || this.map
? this.visibleTrainers
: this.trainers;
if (trainersToShow.length === 0) {
const message = this.trainers.length > 0
? 'No trainers visible in this area. Zoom out or pan the map to see more.'
: 'No trainers found matching your criteria.';
$grid.html('<div class="hvac-no-results"><p>' + message + '</p></div>');
$('.hvac-load-more-wrapper').hide();
return;
}
// Display trainers (show first 12, load more on request)
const displayCount = Math.min(this.trainers.length, 12);
// Display trainers (show first 6 for compact sidebar, load more on request)
const displayCount = Math.min(trainersToShow.length, 6);
for (let i = 0; i < displayCount; i++) {
const trainer = this.trainers[i];
const trainer = trainersToShow[i];
$grid.append(this.createTrainerCard(trainer));
}
// Show load more if there are more trainers
if (this.trainers.length > 12) {
if (trainersToShow.length > 6) {
$('.hvac-load-more-wrapper').show();
} else {
$('.hvac-load-more-wrapper').hide();
@ -632,10 +701,19 @@
/**
* Update counts display
* @param {number} visible - Number of visible trainers (or total if no viewport filtering)
* @param {number} total - Total number of trainers (optional, for "X of Y" format)
*/
updateCounts: function(trainers, venues) {
$('#hvac-trainer-count').text(trainers || 0);
$('#hvac-venue-count').text(venues || 0);
updateCounts: function(visible, total) {
const $count = $('#hvac-trainer-count');
if (total && total !== visible) {
// Show "X of Y" format when viewport is filtering
$count.text(visible + ' of ' + total);
} else {
// Show just the count
$count.text(visible || 0);
}
},
/**
@ -741,17 +819,64 @@
loadMoreTrainers: function() {
const $grid = $('#hvac-trainer-grid');
const currentCount = $grid.find('.hvac-trainer-card').length;
const loadMore = 12;
const loadMore = 6; // Load 6 more at a time for sidebar
for (let i = currentCount; i < currentCount + loadMore && i < this.trainers.length; i++) {
$grid.append(this.createTrainerCard(this.trainers[i]));
// Use visible trainers if available (viewport sync), otherwise use all trainers
const trainersToShow = this.visibleTrainers.length > 0 || this.map
? this.visibleTrainers
: this.trainers;
for (let i = currentCount; i < currentCount + loadMore && i < trainersToShow.length; i++) {
$grid.append(this.createTrainerCard(trainersToShow[i]));
}
if ($grid.find('.hvac-trainer-card').length >= this.trainers.length) {
if ($grid.find('.hvac-trainer-card').length >= trainersToShow.length) {
$('.hvac-load-more-wrapper').hide();
}
},
/**
* Handle window resize for map
*/
handleWindowResize: function() {
const self = this;
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
if (self.map) {
google.maps.event.trigger(self.map, 'resize');
}
}, 250);
});
},
/**
* Initialize sidebar toggle for mobile
*/
initSidebarToggle: function() {
const self = this;
$(document).on('click', '.hvac-sidebar-toggle', function() {
const $sidebar = $('.hvac-sidebar');
const $toggle = $(this);
const isCollapsed = $sidebar.hasClass('collapsed');
$sidebar.toggleClass('collapsed');
// Update ARIA attributes
$toggle.attr('aria-expanded', isCollapsed ? 'true' : 'false');
// Trigger map resize after sidebar animation
setTimeout(function() {
if (self.map) {
google.maps.event.trigger(self.map, 'resize');
}
}, 300);
});
},
/**
* Get user's location
*/
@ -782,6 +907,36 @@
this.map.setZoom(zoom || 10);
},
/**
* Sync sidebar trainer list with visible map viewport
*/
syncSidebarWithViewport: function() {
if (!this.map || this.trainers.length === 0) {
return;
}
// Get current map bounds
const bounds = this.map.getBounds();
if (!bounds) {
return;
}
// Filter trainers to those visible in current viewport
this.visibleTrainers = this.trainers.filter(trainer => {
if (!trainer.lat || !trainer.lng) {
return false;
}
const position = new google.maps.LatLng(trainer.lat, trainer.lng);
return bounds.contains(position);
});
// Update the sidebar grid with visible trainers
this.updateTrainerGrid();
// Update count to show visible vs total
this.updateCounts(this.visibleTrainers.length, this.trainers.length);
},
/**
* Show loading state
*/

View file

@ -31,13 +31,7 @@ class HVAC_Page_Manager {
'parent' => null,
'capability' => null
],
'find-a-trainer' => [
'title' => 'Find a Trainer',
'template' => 'page-find-trainer.php',
'public' => true,
'parent' => null,
'capability' => null
],
// Note: find-a-trainer removed - redirects to find-training (see HVAC_Route_Manager)
'find-training' => [
'title' => 'Find Training',
'template' => 'page-find-training.php',

View file

@ -115,7 +115,7 @@ final class HVAC_Plugin {
define('HVAC_PLUGIN_VERSION', '2.0.0');
}
if (!defined('HVAC_VERSION')) {
define('HVAC_VERSION', '2.1.7');
define('HVAC_VERSION', '2.2.4');
}
if (!defined('HVAC_PLUGIN_FILE')) {
define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php');

View file

@ -81,7 +81,8 @@ class HVAC_Route_Manager {
'communication-templates' => 'trainer/communication-templates',
'communication-schedules' => 'trainer/communication-schedules',
'trainer-registration' => 'trainer/registration',
'find-trainer' => 'find-a-trainer', // Fix E2E testing URL mismatch
'find-trainer' => 'find-training', // Legacy URL redirect
'find-a-trainer' => 'find-training', // Old page redirect to new Google Maps page
);
// Parent pages that redirect to dashboards