Add US geo data tables, filter bounds API, and URL hash state management

- Add mls_geo_cities and mls_geo_zipcodes tables with 29,880 cities and 33,144 zip codes
- Add get_filter_bounds() method to reposition map when filters don't intersect current view
- Move all URL state (filters, page, scroll, map position) to hash to avoid WordPress 404s
- Add filter bounds AJAX endpoint for map repositioning on filter change

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-17 15:07:33 -06:00
parent 5522d18ada
commit 564d556a8c
8 changed files with 63566 additions and 44 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -68,6 +68,20 @@ class MLS_DB {
return $this->get_table_name(MLS_TABLE_MEDIA_LOG);
}
/**
* Get geo cities table name
*/
public function geo_cities_table() {
return $this->get_table_name(MLS_TABLE_GEO_CITIES);
}
/**
* Get geo zipcodes table name
*/
public function geo_zipcodes_table() {
return $this->get_table_name(MLS_TABLE_GEO_ZIPCODES);
}
/**
* Create all database tables
*/
@@ -283,6 +297,41 @@ class MLS_DB {
dbDelta($sql_media_log);
// Geo cities table
$table_geo_cities = $wpdb->prefix . MLS_TABLE_GEO_CITIES;
$sql_geo_cities = "CREATE TABLE {$table_geo_cities} (
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
state_code VARCHAR(2) NOT NULL,
state_name VARCHAR(50) NOT NULL,
city VARCHAR(100) NOT NULL,
county VARCHAR(100) DEFAULT NULL,
latitude DECIMAL(10,6) NOT NULL,
longitude DECIMAL(10,6) NOT NULL,
PRIMARY KEY (id),
KEY state_code (state_code),
KEY city (city),
KEY state_city (state_code, city),
KEY lat_lng (latitude, longitude)
) {$charset_collate};";
dbDelta($sql_geo_cities);
// Geo zipcodes table
$table_geo_zipcodes = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES;
$sql_geo_zipcodes = "CREATE TABLE {$table_geo_zipcodes} (
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
zipcode VARCHAR(10) NOT NULL,
latitude DECIMAL(10,6) NOT NULL,
longitude DECIMAL(10,6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY zipcode (zipcode),
KEY lat_lng (latitude, longitude)
) {$charset_collate};";
dbDelta($sql_geo_zipcodes);
// Run index migrations
self::run_index_migrations();
}
@@ -402,6 +451,8 @@ class MLS_DB {
MLS_TABLE_RATE_LIMITS,
MLS_TABLE_SYNC_LOG,
MLS_TABLE_MEDIA_LOG,
MLS_TABLE_GEO_CITIES,
MLS_TABLE_GEO_ZIPCODES,
);
foreach ($tables as $table) {
@@ -472,4 +523,159 @@ class MLS_DB {
return $stats;
}
/**
* Import geo data from CSV files
* Only imports if tables are empty
*
* @return array Import results with counts
*/
public static function import_geo_data() {
global $wpdb;
$results = array(
'cities_imported' => 0,
'zipcodes_imported' => 0,
'cities_skipped' => false,
'zipcodes_skipped' => false,
);
$cities_table = $wpdb->prefix . MLS_TABLE_GEO_CITIES;
$zipcodes_table = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES;
// Check if tables already have data
$cities_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$cities_table}");
$zipcodes_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$zipcodes_table}");
// Import cities if table is empty
if ($cities_count === 0) {
$cities_file = MLS_PLUGIN_DIR . 'data/us_cities.csv';
if (file_exists($cities_file)) {
$results['cities_imported'] = self::import_cities_csv($cities_file, $cities_table);
}
} else {
$results['cities_skipped'] = true;
}
// Import zipcodes if table is empty
if ($zipcodes_count === 0) {
$zipcodes_file = MLS_PLUGIN_DIR . 'data/us_zipcodes.csv';
if (file_exists($zipcodes_file)) {
$results['zipcodes_imported'] = self::import_zipcodes_csv($zipcodes_file, $zipcodes_table);
}
} else {
$results['zipcodes_skipped'] = true;
}
return $results;
}
/**
* Import cities CSV file
* CSV format: ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE
*
* @param string $file Path to CSV file
* @param string $table Table name
* @return int Number of rows imported
*/
private static function import_cities_csv($file, $table) {
global $wpdb;
$handle = fopen($file, 'r');
if (!$handle) {
return 0;
}
// Skip header row
fgetcsv($handle);
$count = 0;
$batch = array();
$batch_size = 500;
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 7) continue;
// ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE
$batch[] = $wpdb->prepare(
"(%s, %s, %s, %s, %f, %f)",
$row[1], // state_code
$row[2], // state_name
$row[3], // city
$row[4], // county
(float) $row[5], // latitude
(float) $row[6] // longitude
);
if (count($batch) >= $batch_size) {
$values = implode(',', $batch);
$wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}");
$count += count($batch);
$batch = array();
}
}
// Insert remaining rows
if (!empty($batch)) {
$values = implode(',', $batch);
$wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}");
$count += count($batch);
}
fclose($handle);
return $count;
}
/**
* Import zipcodes CSV file
* CSV format: ZIP,LAT,LNG
*
* @param string $file Path to CSV file
* @param string $table Table name
* @return int Number of rows imported
*/
private static function import_zipcodes_csv($file, $table) {
global $wpdb;
$handle = fopen($file, 'r');
if (!$handle) {
return 0;
}
// Skip header row
fgetcsv($handle);
$count = 0;
$batch = array();
$batch_size = 500;
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 3) continue;
// ZIP,LAT,LNG
$batch[] = $wpdb->prepare(
"(%s, %f, %f)",
trim($row[0]), // zipcode
(float) trim($row[1]), // latitude
(float) trim($row[2]) // longitude
);
if (count($batch) >= $batch_size) {
$values = implode(',', $batch);
$wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}");
$count += count($batch);
$batch = array();
}
}
// Insert remaining rows
if (!empty($batch)) {
$values = implode(',', $batch);
$wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}");
$count += count($batch);
}
fclose($handle);
return $count;
}
}
@@ -40,6 +40,16 @@ class MLS_Query {
return 'state_or_province IN (' . implode(',', $states) . ')';
}
/**
* Get the TBD address exclusion filter
* Excludes properties with "TBD" as street number
*
* @return string SQL clause
*/
private function get_tbd_exclusion_filter() {
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
}
/**
* Get properties matching criteria
*
@@ -102,6 +112,9 @@ class MLS_Query {
$where[] = $state_filter;
}
// Exclude TBD addresses
$where[] = $this->get_tbd_exclusion_filter();
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
@@ -330,18 +343,19 @@ class MLS_Query {
$table = $this->db->properties_table();
$state_filter = $this->get_state_filter();
$state_clause = $state_filter ? " AND {$state_filter}" : '';
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
if ($status) {
$cities = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT city FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL{$state_clause}
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL{$state_clause}{$tbd_clause}
ORDER BY city ASC",
$status
));
} else {
$cities = $wpdb->get_col(
"SELECT DISTINCT city FROM {$table}
WHERE mlg_can_view = 1 AND city IS NOT NULL{$state_clause}
WHERE mlg_can_view = 1 AND city IS NOT NULL{$state_clause}{$tbd_clause}
ORDER BY city ASC"
);
}
@@ -361,18 +375,19 @@ class MLS_Query {
$table = $this->db->properties_table();
$state_filter = $this->get_state_filter();
$state_clause = $state_filter ? " AND {$state_filter}" : '';
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
if ($status) {
$counties = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT county FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL{$state_clause}
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL{$state_clause}{$tbd_clause}
ORDER BY county ASC",
$status
));
} else {
$counties = $wpdb->get_col(
"SELECT DISTINCT county FROM {$table}
WHERE mlg_can_view = 1 AND county IS NOT NULL{$state_clause}
WHERE mlg_can_view = 1 AND county IS NOT NULL{$state_clause}{$tbd_clause}
ORDER BY county ASC"
);
}
@@ -400,6 +415,9 @@ class MLS_Query {
$where[] = $state_filter;
}
// Exclude TBD addresses
$where[] = $this->get_tbd_exclusion_filter();
if (!empty($args['status'])) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
@@ -472,9 +490,10 @@ class MLS_Query {
$state_filter = $this->get_state_filter();
$state_clause = $state_filter ? " AND {$state_filter}" : '';
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
$count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1{$state_clause}"
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1{$state_clause}{$tbd_clause}"
);
return (int) $count > 0;
@@ -492,12 +511,13 @@ class MLS_Query {
$table = $this->db->properties_table();
$state_filter = $this->get_state_filter();
$state_clause = $state_filter ? " AND {$state_filter}" : '';
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
if ($status) {
return $wpdb->get_results($wpdb->prepare(
"SELECT property_type, COUNT(*) as count
FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL{$state_clause}
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL{$state_clause}{$tbd_clause}
GROUP BY property_type
ORDER BY count DESC",
$status
@@ -507,7 +527,7 @@ class MLS_Query {
return $wpdb->get_results(
"SELECT property_type, COUNT(*) as count
FROM {$table}
WHERE mlg_can_view = 1 AND property_type IS NOT NULL{$state_clause}
WHERE mlg_can_view = 1 AND property_type IS NOT NULL{$state_clause}{$tbd_clause}
GROUP BY property_type
ORDER BY count DESC"
);
@@ -525,12 +545,13 @@ class MLS_Query {
$table = $this->db->properties_table();
$state_filter = $this->get_state_filter();
$state_clause = $state_filter ? " AND {$state_filter}" : '';
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
if ($status) {
return $wpdb->get_row($wpdb->prepare(
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0{$state_clause}",
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0{$state_clause}{$tbd_clause}",
$status
));
}
@@ -538,7 +559,7 @@ class MLS_Query {
return $wpdb->get_row(
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
FROM {$table}
WHERE mlg_can_view = 1 AND list_price > 0{$state_clause}"
WHERE mlg_can_view = 1 AND list_price > 0{$state_clause}{$tbd_clause}"
);
}
@@ -588,4 +609,89 @@ class MLS_Query {
return $street ?: $location;
}
/**
* Get geographic bounds for filtered properties
* Returns min/max lat/lng for all properties matching the filters
*
* @param array $args Filter arguments (same as get_properties, but bounds is ignored)
* @return array|null Bounds array with sw_lat, sw_lng, ne_lat, ne_lng or null if no results
*/
public function get_filter_bounds($args = array()) {
global $wpdb;
$table = $this->db->properties_table();
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL');
$values = array();
// Add state filter (MN and IA only)
$state_filter = $this->get_state_filter();
if ($state_filter) {
$where[] = $state_filter;
}
// Exclude TBD addresses
$where[] = $this->get_tbd_exclusion_filter();
if (!empty($args['status'])) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
}
if (!empty($args['city'])) {
$where[] = 'city = %s';
$values[] = $args['city'];
}
if (!empty($args['postal_code'])) {
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
if (!empty($args['min_price'])) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
}
if (!empty($args['max_price'])) {
$where[] = 'list_price <= %d';
$values[] = (int) $args['max_price'];
}
if (!empty($args['min_beds'])) {
$where[] = 'bedrooms_total >= %d';
$values[] = (int) $args['min_beds'];
}
$sql = "SELECT
MIN(latitude) as sw_lat,
MIN(longitude) as sw_lng,
MAX(latitude) as ne_lat,
MAX(longitude) as ne_lng
FROM {$table}
WHERE " . implode(' AND ', $where);
if (!empty($values)) {
$result = $wpdb->get_row($wpdb->prepare($sql, $values));
} else {
$result = $wpdb->get_row($sql);
}
if (!$result || $result->sw_lat === null) {
return null;
}
return array(
'sw_lat' => (float) $result->sw_lat,
'sw_lng' => (float) $result->sw_lng,
'ne_lat' => (float) $result->ne_lat,
'ne_lng' => (float) $result->ne_lng,
);
}
}
@@ -31,6 +31,8 @@ define('MLS_TABLE_SYNC_STATE', 'mls_sync_state');
define('MLS_TABLE_RATE_LIMITS', 'mls_rate_limits');
define('MLS_TABLE_SYNC_LOG', 'mls_sync_log');
define('MLS_TABLE_MEDIA_LOG', 'mls_media_log');
define('MLS_TABLE_GEO_CITIES', 'mls_geo_cities');
define('MLS_TABLE_GEO_ZIPCODES', 'mls_geo_zipcodes');
// HomeProz office MLS ID for identifying our listings
define('MLS_HOMEPROZ_OFFICE_ID', 'NST253235');
@@ -390,6 +392,20 @@ function mls_get_property_count($args = array()) {
return $plugin->get_query()->get_count($args);
}
/**
* Get geographic bounds for filtered properties
*
* @param array $args Filter arguments
* @return array|null Bounds with sw_lat, sw_lng, ne_lat, ne_lng
*/
function mls_get_filter_bounds($args = array()) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return null;
}
return $plugin->get_query()->get_filter_bounds($args);
}
/**
* Get all images for a listing (on-demand fetching)
*