Created
July 4, 2025 21:32
-
-
Save amonks/a0f3c9a58551e6468b435f4456c7120a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Package main implements a house hunting management tool for Redfin favorites. | |
// | |
// This program helps manage house search data by importing CSV files downloaded | |
// from Redfin.com favorites and serving them via a web interface. The typical | |
// workflow is: | |
// | |
// 1. Browse houses on Redfin.com and favorite interesting properties | |
// 2. Download the favorites CSV file from Redfin | |
// 3. Run `redfin import <csv-file>` to import into local SQLite database | |
// 4. Run `redfin serve` to view properties in a sortable web table | |
// | |
// The program stores all property data locally in SQLite with WAL mode enabled | |
// for better performance. Duplicate imports are handled gracefully (properties | |
// are identified by their URL). | |
// | |
// Future plans include adding custom metadata fields for personal notes, | |
// ratings, and other house hunting data not available in the Redfin CSV. | |
package main | |
import ( | |
"context" | |
"database/sql" | |
"encoding/csv" | |
"encoding/json" | |
"flag" | |
"fmt" | |
"html/template" | |
"log" | |
"net/http" | |
"os" | |
"os/signal" | |
"regexp" | |
"sort" | |
"strconv" | |
"strings" | |
"syscall" | |
"time" | |
"github.com/PuerkitoBio/goquery" | |
_ "github.com/mattn/go-sqlite3" | |
"golang.org/x/text/language" | |
"golang.org/x/text/message" | |
) | |
const dbPath = "./redfin.db" | |
// DB wraps a SQLite database connection for property data. | |
// Uses WAL mode for better concurrent access performance. | |
type DB struct { | |
conn *sql.DB | |
} | |
// NewDB creates a new database connection and initializes the properties table. | |
// The table schema matches the Redfin CSV export format with sqlite-style column names. | |
// URL is used as the primary key to prevent duplicate property entries. | |
func NewDB(path string) (*DB, error) { | |
conn, err := sql.Open("sqlite3", path) | |
if err != nil { | |
return nil, err | |
} | |
// Enable WAL mode | |
_, err = conn.Exec("PRAGMA journal_mode=WAL") | |
if err != nil { | |
return nil, err | |
} | |
// Create table if it doesn't exist | |
createTableSQL := ` | |
CREATE TABLE IF NOT EXISTS properties ( | |
sale_type TEXT, | |
sold_date TEXT, | |
property_type TEXT, | |
address TEXT, | |
city TEXT, | |
state_or_province TEXT, | |
zip_or_postal_code TEXT, | |
price INTEGER, | |
beds INTEGER, | |
baths REAL, | |
location TEXT, | |
square_feet INTEGER, | |
lot_size INTEGER, | |
year_built INTEGER, | |
days_on_market INTEGER, | |
price_per_square_foot INTEGER, | |
hoa_month INTEGER, | |
status TEXT, | |
next_open_house_start TEXT, | |
next_open_house_end TEXT, | |
url TEXT PRIMARY KEY, | |
source TEXT, | |
mls TEXT, | |
favorite TEXT, | |
interested TEXT, | |
latitude REAL, | |
longitude REAL, | |
finished_sq_ft_from_city INTEGER, | |
unfinished_sq_ft_from_city INTEGER, | |
total_sq_ft_from_city INTEGER, | |
stories_from_city REAL, | |
lot_size_from_city INTEGER, | |
year_built_from_city INTEGER, | |
image_url TEXT, | |
pasted_html TEXT, | |
walk_score INTEGER, | |
transit_score INTEGER, | |
bike_score INTEGER, | |
is_rejected INTEGER DEFAULT 0, | |
is_starred INTEGER DEFAULT 0 | |
);` | |
_, err = conn.Exec(createTableSQL) | |
if err != nil { | |
return nil, err | |
} | |
// Add new columns if they don't exist (for existing databases) | |
alteredColumns := []string{ | |
"ALTER TABLE properties ADD COLUMN finished_sq_ft_from_city INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN unfinished_sq_ft_from_city INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN total_sq_ft_from_city INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN stories_from_city REAL DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN lot_size_from_city INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN year_built_from_city INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN image_url TEXT DEFAULT ''", | |
"ALTER TABLE properties ADD COLUMN pasted_html TEXT DEFAULT ''", | |
"ALTER TABLE properties ADD COLUMN walk_score INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN transit_score INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN bike_score INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN is_rejected INTEGER DEFAULT 0", | |
"ALTER TABLE properties ADD COLUMN is_starred INTEGER DEFAULT 0", | |
} | |
for _, sql := range alteredColumns { | |
_, _ = conn.Exec(sql) // Ignore errors as columns might already exist | |
} | |
return &DB{conn: conn}, nil | |
} | |
// Close closes the database connection. | |
func (db *DB) Close() error { | |
return db.conn.Close() | |
} | |
// UpdateRejectionStatus updates the rejection status for a property. | |
func (db *DB) UpdateRejectionStatus(url string, isRejected bool) error { | |
updateSQL := `UPDATE properties SET is_rejected = ? WHERE url = ?` | |
_, err := db.conn.Exec(updateSQL, isRejected, url) | |
return err | |
} | |
// UpdateStarredStatus updates the starred status for a property. | |
func (db *DB) UpdateStarredStatus(url string, isStarred bool) error { | |
updateSQL := `UPDATE properties SET is_starred = ? WHERE url = ?` | |
_, err := db.conn.Exec(updateSQL, isStarred, url) | |
return err | |
} | |
// UpdateMetadata updates the city clerk metadata for a property. | |
func (db *DB) UpdateMetadata(url string, metadata PropertyMetadata, html string) error { | |
updateSQL := ` | |
UPDATE properties SET | |
finished_sq_ft_from_city = ?, | |
unfinished_sq_ft_from_city = ?, | |
total_sq_ft_from_city = ?, | |
stories_from_city = ?, | |
lot_size_from_city = ?, | |
year_built_from_city = ?, | |
image_url = ?, | |
pasted_html = ?, | |
walk_score = ?, | |
transit_score = ?, | |
bike_score = ? | |
WHERE url = ?` | |
_, err := db.conn.Exec(updateSQL, | |
metadata.FinishedSqFt, metadata.UnfinishedSqFt, metadata.TotalSqFt, | |
metadata.Stories, metadata.LotSize, metadata.YearBuilt, metadata.ImageURL, html, | |
metadata.WalkScore, metadata.TransitScore, metadata.BikeScore, url) | |
return err | |
} | |
// Insert adds a property to the database using INSERT OR IGNORE to handle duplicates. | |
// Properties are identified by URL, so re-importing the same CSV is safe. | |
func (db *DB) Insert(p Property) error { | |
insertSQL := ` | |
INSERT OR IGNORE INTO properties ( | |
sale_type, sold_date, property_type, address, city, state_or_province, | |
zip_or_postal_code, price, beds, baths, location, square_feet, lot_size, | |
year_built, days_on_market, price_per_square_foot, hoa_month, status, | |
next_open_house_start, next_open_house_end, url, source, mls, favorite, | |
interested, latitude, longitude, finished_sq_ft_from_city, unfinished_sq_ft_from_city, | |
total_sq_ft_from_city, stories_from_city, lot_size_from_city, year_built_from_city, | |
image_url, pasted_html, walk_score, transit_score, bike_score, is_rejected, is_starred | |
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` | |
_, err := db.conn.Exec(insertSQL, | |
p.SaleType, p.SoldDate, p.PropertyType, p.Address, p.City, p.StateOrProvince, | |
p.ZipOrPostalCode, p.Price, p.Beds, p.Baths, p.Location, p.SquareFeet, p.LotSize, | |
p.YearBuilt, p.DaysOnMarket, p.PricePerSquareFoot, p.HOAMonth, p.Status, | |
p.NextOpenHouseStart, p.NextOpenHouseEnd, p.URL, p.Source, p.MLS, p.Favorite, | |
p.Interested, p.Latitude, p.Longitude, p.FinishedSqFtFromCity, p.UnfinishedSqFtFromCity, | |
p.TotalSqFtFromCity, p.StoriesFromCity, p.LotSizeFromCity, p.YearBuiltFromCity, | |
p.ImageURL, p.PastedHTML, p.WalkScore, p.TransitScore, p.BikeScore, p.IsRejected, p.IsStarred, | |
) | |
return err | |
} | |
// All returns all properties from the database, ordered by price descending by default. | |
// This method loads the full dataset into memory, which is acceptable for typical | |
// house hunting use cases (hundreds to low thousands of properties). | |
func (db *DB) All() ([]Property, error) { | |
rows, err := db.conn.Query(` | |
SELECT sale_type, sold_date, property_type, address, city, state_or_province, | |
zip_or_postal_code, price, beds, baths, location, square_feet, lot_size, | |
year_built, days_on_market, price_per_square_foot, hoa_month, status, | |
next_open_house_start, next_open_house_end, url, source, mls, favorite, | |
interested, latitude, longitude, finished_sq_ft_from_city, unfinished_sq_ft_from_city, | |
total_sq_ft_from_city, stories_from_city, lot_size_from_city, year_built_from_city, | |
image_url, pasted_html, walk_score, transit_score, bike_score, is_rejected, is_starred | |
FROM properties | |
ORDER BY is_rejected ASC, price DESC | |
`) | |
if err != nil { | |
return nil, err | |
} | |
defer rows.Close() | |
var properties []Property | |
for rows.Next() { | |
var p Property | |
err := rows.Scan( | |
&p.SaleType, &p.SoldDate, &p.PropertyType, &p.Address, &p.City, &p.StateOrProvince, | |
&p.ZipOrPostalCode, &p.Price, &p.Beds, &p.Baths, &p.Location, &p.SquareFeet, &p.LotSize, | |
&p.YearBuilt, &p.DaysOnMarket, &p.PricePerSquareFoot, &p.HOAMonth, &p.Status, | |
&p.NextOpenHouseStart, &p.NextOpenHouseEnd, &p.URL, &p.Source, &p.MLS, &p.Favorite, | |
&p.Interested, &p.Latitude, &p.Longitude, &p.FinishedSqFtFromCity, &p.UnfinishedSqFtFromCity, | |
&p.TotalSqFtFromCity, &p.StoriesFromCity, &p.LotSizeFromCity, &p.YearBuiltFromCity, | |
&p.ImageURL, &p.PastedHTML, &p.WalkScore, &p.TransitScore, &p.BikeScore, &p.IsRejected, &p.IsStarred, | |
) | |
if err != nil { | |
return nil, err | |
} | |
properties = append(properties, p) | |
} | |
return properties, nil | |
} | |
// PropertyMetadata represents city clerk data extracted from HTML. | |
type PropertyMetadata struct { | |
FinishedSqFt int | |
UnfinishedSqFt int | |
TotalSqFt int | |
Stories float64 | |
LotSize int | |
YearBuilt int | |
ImageURL string | |
WalkScore int | |
TransitScore int | |
BikeScore int | |
} | |
// Property represents a real estate listing from Redfin. | |
// Field names and types match the Redfin CSV export format. | |
// All fields are included to preserve the complete dataset for future analysis. | |
type Property struct { | |
SaleType string | |
SoldDate string | |
PropertyType string | |
Address string | |
City string | |
StateOrProvince string | |
ZipOrPostalCode string | |
Price int | |
Beds int | |
Baths float64 | |
Location string | |
SquareFeet int | |
LotSize int | |
YearBuilt int | |
DaysOnMarket int | |
PricePerSquareFoot int | |
HOAMonth int | |
Status string | |
NextOpenHouseStart string | |
NextOpenHouseEnd string | |
URL string | |
Source string | |
MLS string | |
Favorite string | |
Interested string | |
Latitude float64 | |
Longitude float64 | |
// City clerk data (from public facts section) | |
FinishedSqFtFromCity int | |
UnfinishedSqFtFromCity int | |
TotalSqFtFromCity int | |
StoriesFromCity float64 | |
LotSizeFromCity int | |
YearBuiltFromCity int | |
ImageURL string | |
PastedHTML string | |
// Walkability scores | |
WalkScore int | |
TransitScore int | |
BikeScore int | |
// Rejection status | |
IsRejected bool | |
// Starred status | |
IsStarred bool | |
} | |
// main implements the CLI interface with two commands: | |
// - import: Parse and import a Redfin CSV file into the database | |
// - serve: Start HTTP server to browse properties in a web table | |
func main() { | |
var addr string | |
flag.StringVar(&addr, "addr", "0.0.0.0:5566", "HTTP server address") | |
flag.Parse() | |
args := flag.Args() | |
if len(args) == 0 { | |
fmt.Println("Usage: redfin <command> [args]") | |
fmt.Println("Commands:") | |
fmt.Println(" import <csv-file> Import CSV file into database") | |
fmt.Println(" serve Start HTTP server") | |
fmt.Println(" reparse Re-parse HTML for all properties with stored HTML") | |
os.Exit(1) | |
} | |
switch args[0] { | |
case "import": | |
if len(args) < 2 { | |
fmt.Println("Usage: redfin import <csv-file>") | |
os.Exit(1) | |
} | |
importCSV(args[1]) | |
case "serve": | |
serve(addr) | |
case "reparse": | |
reparseHTML() | |
default: | |
fmt.Printf("Unknown command: %s\n", args[0]) | |
os.Exit(1) | |
} | |
} | |
// importCSV parses a Redfin favorites CSV file and imports all properties into the database. | |
// The CSV format is the standard export from Redfin.com favorites page. | |
// Handles incomplete records gracefully and reports the number of successfully imported properties. | |
// Duplicate properties (same URL) are ignored, making it safe to re-import updated CSV files. | |
func importCSV(filename string) { | |
db, err := NewDB(dbPath) | |
if err != nil { | |
log.Fatal(err) | |
} | |
defer db.Close() | |
file, err := os.Open(filename) | |
if err != nil { | |
log.Fatal(err) | |
} | |
defer file.Close() | |
reader := csv.NewReader(file) | |
records, err := reader.ReadAll() | |
if err != nil { | |
log.Fatal(err) | |
} | |
if len(records) == 0 { | |
log.Fatal("CSV file is empty") | |
} | |
// Skip header row | |
records = records[1:] | |
imported := 0 | |
for _, record := range records { | |
if len(record) < 27 { | |
continue // Skip incomplete records | |
} | |
// Parse numeric fields | |
price, _ := strconv.Atoi(record[7]) | |
beds, _ := strconv.Atoi(record[8]) | |
baths, _ := strconv.ParseFloat(record[9], 64) | |
squareFeet, _ := strconv.Atoi(record[11]) | |
lotSize, _ := strconv.Atoi(record[12]) | |
yearBuilt, _ := strconv.Atoi(record[13]) | |
daysOnMarket, _ := strconv.Atoi(record[14]) | |
pricePerSquareFoot, _ := strconv.Atoi(record[15]) | |
hoaMonth, _ := strconv.Atoi(record[16]) | |
latitude, _ := strconv.ParseFloat(record[25], 64) | |
longitude, _ := strconv.ParseFloat(record[26], 64) | |
p := Property{ | |
SaleType: record[0], | |
SoldDate: record[1], | |
PropertyType: record[2], | |
Address: record[3], | |
City: record[4], | |
StateOrProvince: record[5], | |
ZipOrPostalCode: record[6], | |
Price: price, | |
Beds: beds, | |
Baths: baths, | |
Location: record[10], | |
SquareFeet: squareFeet, | |
LotSize: lotSize, | |
YearBuilt: yearBuilt, | |
DaysOnMarket: daysOnMarket, | |
PricePerSquareFoot: pricePerSquareFoot, | |
HOAMonth: hoaMonth, | |
Status: record[17], | |
NextOpenHouseStart: record[18], | |
NextOpenHouseEnd: record[19], | |
URL: record[20], | |
Source: record[21], | |
MLS: record[22], | |
Favorite: record[23], | |
Interested: record[24], | |
Latitude: latitude, | |
Longitude: longitude, | |
} | |
err := db.Insert(p) | |
if err != nil { | |
log.Printf("Error inserting record: %v", err) | |
continue | |
} | |
imported++ | |
} | |
fmt.Printf("Imported %d properties\n", imported) | |
} | |
// reparseHTML re-parses HTML for all properties that have stored HTML. | |
// This allows updating metadata extraction logic without re-pasting HTML. | |
func reparseHTML() { | |
db, err := NewDB(dbPath) | |
if err != nil { | |
log.Fatal(err) | |
} | |
defer db.Close() | |
// Get all properties with stored HTML | |
rows, err := db.conn.Query("SELECT url, pasted_html FROM properties WHERE pasted_html IS NOT NULL AND pasted_html != ''") | |
if err != nil { | |
log.Fatal(err) | |
} | |
defer rows.Close() | |
var properties []struct { | |
URL string | |
HTML string | |
} | |
for rows.Next() { | |
var p struct { | |
URL string | |
HTML string | |
} | |
err := rows.Scan(&p.URL, &p.HTML) | |
if err != nil { | |
log.Printf("Error scanning row: %v", err) | |
continue | |
} | |
properties = append(properties, p) | |
} | |
fmt.Printf("Found %d properties with stored HTML\n", len(properties)) | |
updated := 0 | |
for _, prop := range properties { | |
metadata, err := parseHTML(prop.HTML) | |
if err != nil { | |
log.Printf("Error parsing HTML for %s: %v", prop.URL, err) | |
continue | |
} | |
err = db.UpdateMetadata(prop.URL, metadata, prop.HTML) | |
if err != nil { | |
log.Printf("Error updating metadata for %s: %v", prop.URL, err) | |
continue | |
} | |
updated++ | |
} | |
fmt.Printf("Successfully re-parsed %d properties\n", updated) | |
} | |
// parseHTML extracts property metadata from Redfin listing HTML. | |
func parseHTML(htmlContent string) (PropertyMetadata, error) { | |
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) | |
if err != nil { | |
return PropertyMetadata{}, err | |
} | |
var metadata PropertyMetadata | |
// Parse public facts entries | |
doc.Find("li.entryItem span.entryItemContent").Each(func(i int, s *goquery.Selection) { | |
text := s.Text() | |
if strings.Contains(text, ":") { | |
parts := strings.SplitN(text, ":", 2) | |
if len(parts) == 2 { | |
fieldName := strings.TrimSpace(parts[0]) | |
fieldValue := strings.TrimSpace(parts[1]) | |
// Extract numeric value from field | |
numericValue := extractNumericValue(fieldValue) | |
switch fieldName { | |
case "Finished Sq. Ft.": | |
metadata.FinishedSqFt = numericValue | |
case "Unfinished Sq. Ft.": | |
metadata.UnfinishedSqFt = numericValue | |
case "Total Sq. Ft.": | |
metadata.TotalSqFt = numericValue | |
case "Stories": | |
if val, err := strconv.ParseFloat(strings.ReplaceAll(fieldValue, ",", ""), 64); err == nil { | |
metadata.Stories = val | |
} | |
case "Lot Size": | |
metadata.LotSize = numericValue | |
case "Year Built": | |
metadata.YearBuilt = numericValue | |
} | |
} | |
} | |
}) | |
// Extract image URL from #MBImage (can be div or span) | |
mbImage := doc.Find("#MBImage") | |
if mbImage.Length() > 0 { | |
// Look for img tag within the element | |
img := mbImage.Find("img").First() | |
if img.Length() > 0 { | |
if src, exists := img.Attr("src"); exists { | |
metadata.ImageURL = src | |
} | |
} | |
} | |
// Extract walkability scores from JavaScript JSON using regex | |
htmlText := htmlContent | |
// Walk Score: ,"walkScore":{"value":91.0, | |
walkScoreRegex := regexp.MustCompile(`,\\?"walkScoreData\\?":\\?\{\\?"walkScore\\?":\\?\{\\?"value\\?":([0-9]+(?:\.[0-9]+)?)`) | |
if matches := walkScoreRegex.FindStringSubmatch(htmlText); len(matches) > 1 { | |
if val, err := strconv.ParseFloat(matches[1], 64); err == nil { | |
metadata.WalkScore = int(val) | |
} | |
} | |
// Transit Score: ,"transitScore":{"value":81.0, | |
transitScoreRegex := regexp.MustCompile(`,\\?"transitScore\\?":\\?\{\\?"value\\?":([0-9]+(?:\.[0-9]+)?)`) | |
if matches := transitScoreRegex.FindStringSubmatch(htmlText); len(matches) > 1 { | |
if val, err := strconv.ParseFloat(matches[1], 64); err == nil { | |
metadata.TransitScore = int(val) | |
} | |
} | |
// Bike Score: ,"bikeScore":{"value":90.0, | |
bikeScoreRegex := regexp.MustCompile(`,\\?"bikeScore\\?":\\?\{\\?"value\\?":([0-9]+(?:\.[0-9]+)?)`) | |
if matches := bikeScoreRegex.FindStringSubmatch(htmlText); len(matches) > 1 { | |
if val, err := strconv.ParseFloat(matches[1], 64); err == nil { | |
metadata.BikeScore = int(val) | |
} | |
} | |
return metadata, nil | |
} | |
// validateAndParseHTML validates that the HTML belongs to the correct property | |
// by checking the canonical URL, then parses the metadata. | |
func validateAndParseHTML(htmlContent, expectedURL string) (PropertyMetadata, error) { | |
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) | |
if err != nil { | |
return PropertyMetadata{}, fmt.Errorf("failed to parse HTML: %v", err) | |
} | |
// Extract canonical URL | |
canonicalURL := "" | |
doc.Find("link[rel='canonical']").Each(func(i int, s *goquery.Selection) { | |
if href, exists := s.Attr("href"); exists { | |
canonicalURL = href | |
return | |
} | |
}) | |
if canonicalURL == "" { | |
return PropertyMetadata{}, fmt.Errorf("no canonical URL found in HTML") | |
} | |
// Compare URLs (normalize by removing trailing slashes and converting to lowercase) | |
normalizeURL := func(url string) string { | |
url = strings.ToLower(strings.TrimSpace(url)) | |
return strings.TrimSuffix(url, "/") | |
} | |
normalizedCanonical := normalizeURL(canonicalURL) | |
normalizedExpected := normalizeURL(expectedURL) | |
if normalizedCanonical != normalizedExpected { | |
return PropertyMetadata{}, fmt.Errorf("HTML canonical URL (%s) does not match expected property URL (%s)", canonicalURL, expectedURL) | |
} | |
// If validation passes, parse the metadata | |
return parseHTML(htmlContent) | |
} | |
// extractNumericValue extracts the first numeric value from a string. | |
func extractNumericValue(s string) int { | |
// Remove commas and extract digits | |
re := regexp.MustCompile(`[0-9,]+`) | |
numStr := re.FindString(s) | |
numStr = strings.ReplaceAll(numStr, ",", "") | |
if val, err := strconv.Atoi(numStr); err == nil { | |
return val | |
} | |
return 0 | |
} | |
// serve starts an HTTP server that displays properties in a sortable web table. | |
// Supports sorting by any column via ?sort=<column> query parameters. | |
// Uses Tailwind CSS for styling and formats numbers with English locale conventions. | |
// The server loads all properties into memory on each request, which is efficient | |
// for typical house hunting datasets. | |
func serve(addr string) { | |
db, err := NewDB(dbPath) | |
if err != nil { | |
log.Fatal(err) | |
} | |
defer db.Close() | |
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | |
properties, err := db.All() | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
// Sort based on query parameter | |
sortCol := r.URL.Query().Get("sort") | |
if sortCol != "" { | |
switch sortCol { | |
case "address": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].Address < properties[j].Address | |
}) | |
case "type": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].PropertyType < properties[j].PropertyType | |
}) | |
case "price": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].Price > properties[j].Price | |
}) | |
case "beds": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].Beds > properties[j].Beds | |
}) | |
case "sqft": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].SquareFeet > properties[j].SquareFeet | |
}) | |
case "year": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].YearBuilt > properties[j].YearBuilt | |
}) | |
case "days": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].DaysOnMarket < properties[j].DaysOnMarket | |
}) | |
case "lot": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].LotSize > properties[j].LotSize | |
}) | |
case "status": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].Status < properties[j].Status | |
}) | |
case "finished_sqft": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].FinishedSqFtFromCity > properties[j].FinishedSqFtFromCity | |
}) | |
case "unfinished_sqft": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].UnfinishedSqFtFromCity > properties[j].UnfinishedSqFtFromCity | |
}) | |
case "stories": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].StoriesFromCity > properties[j].StoriesFromCity | |
}) | |
case "walk_score": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].WalkScore > properties[j].WalkScore | |
}) | |
case "transit_score": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].TransitScore > properties[j].TransitScore | |
}) | |
case "bike_score": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].BikeScore > properties[j].BikeScore | |
}) | |
case "starred": | |
sort.Slice(properties, func(i, j int) bool { | |
if properties[i].IsRejected != properties[j].IsRejected { | |
return !properties[i].IsRejected | |
} | |
return properties[i].IsStarred | |
}) | |
} | |
} | |
// Create printer for number formatting | |
p := message.NewPrinter(language.English) | |
// Create template with custom functions | |
tmpl := template.Must(template.New("index").Funcs(template.FuncMap{ | |
"formatPrice": func(price int) string { | |
return p.Sprintf("$%d", price) | |
}, | |
"formatNumber": func(num int) string { | |
return p.Sprintf("%d", num) | |
}, | |
}).Parse(htmlTemplate)) | |
err = tmpl.Execute(w, properties) | |
if err != nil { | |
http.Error(w, err.Error(), http.StatusInternalServerError) | |
return | |
} | |
}) | |
// Add API endpoint for rejection status updates | |
http.HandleFunc("/api/reject", func(w http.ResponseWriter, r *http.Request) { | |
if r.Method != "POST" { | |
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
return | |
} | |
var req struct { | |
URL string `json:"url"` | |
IsRejected bool `json:"is_rejected"` | |
} | |
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
http.Error(w, "Invalid JSON", http.StatusBadRequest) | |
return | |
} | |
if err := db.UpdateRejectionStatus(req.URL, req.IsRejected); err != nil { | |
http.Error(w, "Failed to update rejection status: "+err.Error(), http.StatusInternalServerError) | |
return | |
} | |
w.Header().Set("Content-Type", "application/json") | |
json.NewEncoder(w).Encode(map[string]string{"status": "success"}) | |
}) | |
// Add API endpoint for starred status updates | |
http.HandleFunc("/api/star", func(w http.ResponseWriter, r *http.Request) { | |
if r.Method != "POST" { | |
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
return | |
} | |
var req struct { | |
URL string `json:"url"` | |
IsStarred bool `json:"is_starred"` | |
} | |
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
http.Error(w, "Invalid JSON", http.StatusBadRequest) | |
return | |
} | |
if err := db.UpdateStarredStatus(req.URL, req.IsStarred); err != nil { | |
http.Error(w, "Failed to update starred status: "+err.Error(), http.StatusInternalServerError) | |
return | |
} | |
w.Header().Set("Content-Type", "application/json") | |
json.NewEncoder(w).Encode(map[string]string{"status": "success"}) | |
}) | |
// Add API endpoint for metadata updates | |
http.HandleFunc("/api/metadata", func(w http.ResponseWriter, r *http.Request) { | |
if r.Method != "POST" { | |
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
return | |
} | |
var req struct { | |
URL string `json:"url"` | |
HTML string `json:"html"` | |
} | |
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
http.Error(w, "Invalid JSON", http.StatusBadRequest) | |
return | |
} | |
metadata, err := validateAndParseHTML(req.HTML, req.URL) | |
if err != nil { | |
http.Error(w, "Failed to validate or parse HTML: "+err.Error(), http.StatusBadRequest) | |
return | |
} | |
if err := db.UpdateMetadata(req.URL, metadata, req.HTML); err != nil { | |
http.Error(w, "Failed to update metadata: "+err.Error(), http.StatusInternalServerError) | |
return | |
} | |
w.Header().Set("Content-Type", "application/json") | |
json.NewEncoder(w).Encode(map[string]string{"status": "success"}) | |
}) | |
// Create HTTP server | |
server := &http.Server{ | |
Addr: addr, | |
} | |
// Set up signal handling for graceful shutdown | |
sigChan := make(chan os.Signal, 1) | |
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |
// Start server in a goroutine | |
go func() { | |
fmt.Printf("Server starting on %s\n", addr) | |
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |
log.Fatal(err) | |
} | |
}() | |
// Wait for interrupt signal | |
<-sigChan | |
fmt.Printf("\nShutting down server gracefully...\n") | |
// Create a context with timeout for graceful shutdown | |
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
defer cancel() | |
// Shutdown the server | |
if err := server.Shutdown(ctx); err != nil { | |
log.Printf("Server forced to shutdown: %v", err) | |
} | |
fmt.Printf("Server stopped\n") | |
} | |
const htmlTemplate = ` | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Redfin Properties</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<style>html { height: 100%; }</style> | |
</head> | |
<body class="h-full m-0 p-0"> | |
<div class="h-screen flex flex-col"> | |
<!-- Map Section --> | |
<div class="h-1/2 w-full min-h-[200px] relative z-0" id="mapContainer"> | |
<div id="map" class="h-full w-full"></div> | |
</div> | |
<!-- Resize Handle --> | |
<div class="h-1 bg-gray-300 hover:bg-gray-400 cursor-row-resize relative z-10" id="resizeHandle"></div> | |
<!-- Table Section --> | |
<div class="flex-1 min-h-[200px] flex flex-col bg-gray-100" id="tableContainer"> | |
<div class="flex-1 flow-root overflow-auto"> | |
<div class="-my-2"> | |
<div class="inline-block min-w-full py-2 align-middle"> | |
<table class="min-w-full border-separate border-spacing-0"> | |
<thead> | |
<tr> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 py-3.5 pr-3 pl-6 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter">Image</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=address" class="hover:text-gray-700">Address</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=type" class="hover:text-gray-700">Type</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=price" class="hover:text-gray-700">Price</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=beds" class="hover:text-gray-700">Beds/Baths</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=sqft" class="hover:text-gray-700">Sq Ft<br><span class="text-xs font-normal">listing (city)</span></a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=year" class="hover:text-gray-700">Year Built<br><span class="text-xs font-normal">listing (city)</span></a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=days" class="hover:text-gray-700">Days on Market</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=lot" class="hover:text-gray-700">Lot Size<br><span class="text-xs font-normal">listing (city)</span></a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=status" class="hover:text-gray-700">Status</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=finished_sqft" class="hover:text-gray-700">Finished Sq Ft</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=unfinished_sqft" class="hover:text-gray-700">Unfinished Sq Ft</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=stories" class="hover:text-gray-700">Stories</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=walk_score" class="hover:text-gray-700">Walk</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=transit_score" class="hover:text-gray-700">Transit</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=bike_score" class="hover:text-gray-700">Bike</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter">Parsing</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 px-3 py-3.5 text-left text-sm font-semibold text-gray-900 backdrop-blur-sm backdrop-filter"> | |
<a href="?sort=starred" class="hover:text-gray-700">Star</a> | |
</th> | |
<th scope="col" class="sticky top-0 z-10 border-b border-gray-300 bg-white/75 py-3.5 pr-6 pl-3 backdrop-blur-sm backdrop-filter">Reject</th> | |
</tr> | |
</thead> | |
<tbody> | |
{{range $index, $property := .}} | |
<tr class="hover:bg-gray-50" data-property-index="{{$index}}" onmouseover="highlightMarker({{$index}})" onmouseout="unhighlightMarker({{$index}})"> | |
<td class="border-b border-gray-200 py-4 pr-3 pl-6 text-sm font-medium whitespace-nowrap text-gray-900 min-w-64"> | |
{{if $property.ImageURL}} | |
<img src="{{$property.ImageURL}}" alt="Property" class="w-64 rounded cursor-pointer hover:opacity-80 transition-opacity" onclick="window.open('{{$property.URL}}', '_blank')"> | |
{{else}} | |
<div class="w-64 h-48 bg-gray-200 rounded flex items-center justify-center text-xs text-gray-500 cursor-pointer hover:bg-gray-300 transition-colors" onclick="window.open('{{$property.URL}}', '_blank')">No Image</div> | |
{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
<div class="text-sm font-medium text-gray-900">{{$property.Address}}</div> | |
<div class="text-sm text-gray-500">{{$property.City}}</div> | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500">{{$property.PropertyType}}</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500">{{formatPrice $property.Price}}</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500">{{$property.Beds}}bd / {{$property.Baths}}ba</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.SquareFeet}}<span title="from listing">{{formatNumber $property.SquareFeet}}</span>{{if $property.TotalSqFtFromCity}} <span title="from city">({{formatNumber $property.TotalSqFtFromCity}})</span>{{end}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.YearBuilt}}<span title="from listing">{{$property.YearBuilt}}</span>{{if $property.YearBuiltFromCity}} <span title="from city">({{$property.YearBuiltFromCity}})</span>{{end}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500">{{if $property.DaysOnMarket}}{{$property.DaysOnMarket}}{{else}}-{{end}}</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.LotSize}}<span title="from listing">{{formatNumber $property.LotSize}}</span>{{if $property.LotSizeFromCity}} <span title="from city">({{formatNumber $property.LotSizeFromCity}})</span>{{end}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full {{if eq $property.Status "Active"}}bg-green-100 text-green-800{{else if eq $property.Status "Contingent"}}bg-yellow-100 text-yellow-800{{else}}bg-gray-100 text-gray-800{{end}}"> | |
{{$property.Status}} | |
</span> | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.FinishedSqFtFromCity}}{{formatNumber $property.FinishedSqFtFromCity}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.UnfinishedSqFtFromCity}}{{formatNumber $property.UnfinishedSqFtFromCity}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.StoriesFromCity}}{{$property.StoriesFromCity}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.WalkScore}}{{$property.WalkScore}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.TransitScore}}{{$property.TransitScore}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.BikeScore}}{{$property.BikeScore}}{{else}}-{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.PastedHTML}} | |
<span class="text-green-600 text-lg">✓</span> | |
{{else}} | |
<button onclick="openMetadataModal('{{$property.URL}}')" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded text-xs"> | |
Add HTML | |
</button> | |
{{end}} | |
</td> | |
<td class="border-b border-gray-200 px-3 py-4 text-sm whitespace-nowrap text-gray-500"> | |
{{if $property.IsStarred}} | |
<button onclick="toggleStar('{{$property.URL}}', false)" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-1 px-2 rounded text-xs"> | |
⭐ Starred | |
</button> | |
{{else}} | |
<button onclick="toggleStar('{{$property.URL}}', true)" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-1 px-2 rounded text-xs"> | |
☆ Star | |
</button> | |
{{end}} | |
</td> | |
<td class="relative border-b border-gray-200 py-4 pr-6 pl-3 text-right text-sm font-medium whitespace-nowrap"> | |
{{if $property.IsRejected}} | |
<button onclick="toggleReject('{{$property.URL}}', false)" class="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-2 rounded text-xs"> | |
Unreject | |
</button> | |
{{else}} | |
<button onclick="toggleReject('{{$property.URL}}', true)" class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-xs"> | |
Reject | |
</button> | |
{{end}} | |
</td> | |
</tr> | |
{{end}} | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Metadata Modal --> | |
<div id="metadataModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden"> | |
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white"> | |
<div class="mt-3 text-center"> | |
<h3 class="text-lg leading-6 font-medium text-gray-900">Add Property Metadata</h3> | |
<div class="mt-2"> | |
<p class="text-sm text-gray-500">Paste the HTML from the Redfin listing page to extract public facts.</p> | |
<textarea id="htmlInput" rows="10" class="mt-2 w-full p-2 border border-gray-300 rounded-md" placeholder="Paste HTML here..." autofocus></textarea> | |
</div> | |
<div class="items-center px-4 py-3"> | |
<button id="submitMetadata" class="px-4 py-2 bg-blue-500 text-white text-base font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300"> | |
Parse and Save | |
</button> | |
<button onclick="closeMetadataModal()" class="ml-2 px-4 py-2 bg-gray-500 text-white text-base font-medium rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-300"> | |
Cancel | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
let currentPropertyURL = ''; | |
let map; | |
let markers = []; | |
const properties = {{.}}; | |
// Google Maps initialization - must be global for API callback | |
function initMap() { | |
// Default center (Chicago) | |
const defaultCenter = { lat: 41.8781, lng: -87.6298 }; | |
map = new google.maps.Map(document.getElementById("map"), { | |
zoom: 11, | |
center: defaultCenter, | |
mapTypeId: 'roadmap' | |
}); | |
// Add markers for each property | |
const bounds = new google.maps.LatLngBounds(); | |
let hasValidCoordinates = false; | |
properties.forEach(function(property, index) { | |
if (property.Latitude && property.Longitude && property.Latitude !== 0 && property.Longitude !== 0) { | |
const position = { | |
lat: property.Latitude, | |
lng: property.Longitude | |
}; | |
// Choose pin color based on rejection status | |
const pinColor = property.IsRejected ? '#6b7280' : '#ef4444'; // grey for rejected, red for normal | |
const marker = new google.maps.Marker({ | |
position: position, | |
map: map, | |
title: property.Address, | |
icon: { | |
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent( | |
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' + | |
'<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" fill="' + pinColor + '"/>' + | |
'<circle cx="12" cy="9" r="2.5" fill="white"/>' + | |
'</svg>' | |
), | |
scaledSize: new google.maps.Size(24, 24), | |
anchor: new google.maps.Point(12, 24) | |
}, | |
propertyIndex: index | |
}); | |
// Create info window | |
const infoWindow = new google.maps.InfoWindow({ | |
content: createInfoWindowContent(property) | |
}); | |
marker.addListener('click', function() { | |
// Close any open info windows | |
markers.forEach(m => m.infoWindow && m.infoWindow.close()); | |
infoWindow.open(map, marker); | |
}); | |
markers.push({ marker: marker, infoWindow: infoWindow, propertyIndex: index }); | |
bounds.extend(position); | |
hasValidCoordinates = true; | |
} | |
}); | |
// Fit map to show all markers, or use default center if no valid coordinates | |
if (hasValidCoordinates && !bounds.isEmpty()) { | |
map.fitBounds(bounds); | |
// Ensure minimum zoom level | |
google.maps.event.addListenerOnce(map, 'bounds_changed', function() { | |
if (map.getZoom() > 15) { | |
map.setZoom(15); | |
} | |
}); | |
} else { | |
map.setCenter(defaultCenter); | |
map.setZoom(11); | |
} | |
} | |
function createInfoWindowContent(property) { | |
const formatter = new Intl.NumberFormat('en-US'); | |
const imageHtml = property.ImageURL | |
? '<img src="' + property.ImageURL + '" alt="Property" style="width: 200px; height: 150px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;">' | |
: ''; | |
return '<div style="max-width: 250px;">' + | |
imageHtml + | |
'<div style="font-weight: bold; font-size: 14px; margin-bottom: 4px;">' + property.Address + '</div>' + | |
'<div style="color: #666; font-size: 12px; margin-bottom: 4px;">' + property.City + '</div>' + | |
'<div style="font-weight: bold; font-size: 16px; color: #059669; margin-bottom: 4px;">$' + formatter.format(property.Price) + '</div>' + | |
'<div style="font-size: 12px; color: #666; margin-bottom: 4px;">' + property.Beds + ' beds • ' + property.Baths + ' baths</div>' + | |
(property.SquareFeet ? '<div style="font-size: 12px; color: #666; margin-bottom: 8px;">' + formatter.format(property.SquareFeet) + ' sq ft</div>' : '') + | |
'<a href="' + property.URL + '" target="_blank" style="color: #3b82f6; text-decoration: none; font-size: 12px;">View on Redfin →</a>' + | |
'</div>'; | |
} | |
// Marker highlighting functions | |
function highlightMarker(propertyIndex) { | |
const markerData = markers.find(m => m.propertyIndex === propertyIndex); | |
if (markerData) { | |
// Create highlighted icon (yellow) | |
const highlightedIcon = { | |
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent( | |
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' + | |
'<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" fill="#fbbf24"/>' + | |
'<circle cx="12" cy="9" r="2.5" fill="white"/>' + | |
'</svg>' | |
), | |
scaledSize: new google.maps.Size(24, 24), | |
anchor: new google.maps.Point(12, 24) | |
}; | |
markerData.marker.setIcon(highlightedIcon); | |
markerData.marker.setZIndex(1000); | |
} | |
} | |
function unhighlightMarker(propertyIndex) { | |
const markerData = markers.find(m => m.propertyIndex === propertyIndex); | |
if (markerData) { | |
const property = properties[propertyIndex]; | |
const pinColor = property.IsRejected ? '#6b7280' : '#ef4444'; | |
// Restore original icon with correct color | |
const originalIcon = { | |
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent( | |
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' + | |
'<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" fill="' + pinColor + '"/>' + | |
'<circle cx="12" cy="9" r="2.5" fill="white"/>' + | |
'</svg>' | |
), | |
scaledSize: new google.maps.Size(24, 24), | |
anchor: new google.maps.Point(12, 24) | |
}; | |
markerData.marker.setIcon(originalIcon); | |
markerData.marker.setZIndex(null); | |
} | |
} | |
// Reject/unreject functionality | |
function toggleReject(url, isRejected) { | |
fetch('/api/reject', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
url: url, | |
is_rejected: isRejected | |
}) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (data.status === 'success') { | |
window.location.reload(); | |
} else { | |
alert('Failed to update rejection status'); | |
} | |
}) | |
.catch(error => { | |
console.error('Error:', error); | |
alert('An error occurred while updating rejection status'); | |
}); | |
} | |
// Star/unstar functionality | |
function toggleStar(url, isStarred) { | |
fetch('/api/star', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
url: url, | |
is_starred: isStarred | |
}) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (data.status === 'success') { | |
window.location.reload(); | |
} else { | |
alert('Failed to update starred status'); | |
} | |
}) | |
.catch(error => { | |
console.error('Error:', error); | |
alert('An error occurred while updating starred status'); | |
}); | |
} | |
// Make functions globally available | |
window.initMap = initMap; | |
window.highlightMarker = highlightMarker; | |
window.unhighlightMarker = unhighlightMarker; | |
window.toggleReject = toggleReject; | |
window.toggleStar = toggleStar; | |
// Resize handle functionality | |
let isResizing = false; | |
document.getElementById('resizeHandle').addEventListener('mousedown', function(e) { | |
isResizing = true; | |
document.body.style.cursor = 'row-resize'; | |
document.body.style.userSelect = 'none'; | |
}); | |
document.addEventListener('mousemove', function(e) { | |
if (!isResizing) return; | |
const container = document.body; | |
const containerRect = container.getBoundingClientRect(); | |
const containerHeight = window.innerHeight; | |
const mouseY = e.clientY; | |
// Calculate percentage (minimum 15%, maximum 85%) | |
let percentage = (mouseY / containerHeight) * 100; | |
percentage = Math.max(15, Math.min(85, percentage)); | |
const mapContainer = document.getElementById('mapContainer'); | |
const tableContainer = document.getElementById('tableContainer'); | |
mapContainer.style.height = percentage + 'vh'; | |
// Force Google Maps to resize | |
if (window.map) { | |
setTimeout(() => { | |
google.maps.event.trigger(map, 'resize'); | |
}, 100); | |
} | |
}); | |
document.addEventListener('mouseup', function() { | |
if (isResizing) { | |
isResizing = false; | |
document.body.style.cursor = ''; | |
document.body.style.userSelect = ''; | |
} | |
}); | |
function openMetadataModal(url) { | |
currentPropertyURL = url; | |
document.getElementById('metadataModal').classList.remove('hidden'); | |
const textarea = document.getElementById('htmlInput'); | |
textarea.value = ''; | |
// Focus the textarea after a brief delay to ensure modal is fully rendered | |
setTimeout(() => textarea.focus(), 100); | |
} | |
function closeMetadataModal() { | |
document.getElementById('metadataModal').classList.add('hidden'); | |
currentPropertyURL = ''; | |
} | |
document.getElementById('submitMetadata').addEventListener('click', function() { | |
const html = document.getElementById('htmlInput').value; | |
if (!html.trim()) { | |
alert('Please paste HTML content'); | |
return; | |
} | |
fetch('/api/metadata', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
url: currentPropertyURL, | |
html: html | |
}) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (data.status === 'success') { | |
closeMetadataModal(); | |
window.location.reload(); | |
} else { | |
alert('Failed to update metadata'); | |
} | |
}) | |
.catch(error => { | |
console.error('Error:', error); | |
alert('An error occurred while updating metadata'); | |
}); | |
}); | |
// Add keyboard shortcuts for modal | |
document.addEventListener('keydown', function(event) { | |
const modal = document.getElementById('metadataModal'); | |
const isModalOpen = !modal.classList.contains('hidden'); | |
if (isModalOpen) { | |
if (event.key === 'Escape') { | |
event.preventDefault(); | |
closeMetadataModal(); | |
} else if (event.key === 'Enter' && event.ctrlKey) { | |
event.preventDefault(); | |
document.getElementById('submitMetadata').click(); | |
} | |
} | |
}); | |
// Add textarea-specific keyboard handler for Enter without Ctrl | |
document.getElementById('htmlInput').addEventListener('keydown', function(event) { | |
if (event.key === 'Enter' && !event.ctrlKey && !event.shiftKey) { | |
event.preventDefault(); | |
document.getElementById('submitMetadata').click(); | |
} | |
}); | |
</script> | |
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAF8ubTj3ei3wmpGN-Ah9vPIdGrFnDoluY&callback=initMap" async defer></script> | |
</body> | |
</html> | |
` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment