🏠 RI Real Estate Hub

Technical Documentation & Learning Guide

Built with ❤️ by Nick + Supernova

📚 Table of Contents

1. Architecture Overview

36K+
Properties
2.2MB
Gzipped Data
77
RI Towns
~3s
Load Time
📁 File Structure
IT-consulting-template/
├── ri-report.html      # Main dashboard (5 tabs)
├── ri-results.html     # City drill-down page
├── ri-sales.json.gz    # Compressed property data (2.2MB)
├── ri-docs.html        # This documentation
└── assets/
    └── bridge.jpg      # Hero image
Key Insight: We moved from inline data (5MB HTML) to external gzipped JSON (209KB HTML + 2.2MB data), reducing initial load by 96%.

2. Data Loading & Optimization

Parallel Loading Pattern

Instead of sequential loading (data → then → SQL.js), we load both simultaneously:

// BEFORE: Sequential (slow)
const data = await fetch('ri-sales.json.gz');
const SQL = await initSqlJs(); // Waits for data first

// AFTER: Parallel (fast)
const [dataResponse, SQL] = await Promise.all([
    fetch('ri-sales.json.gz?v=' + today),
    initSqlJs({ locateFile: f => cdn + f })
]);

// Decompress with pako
const compressed = await dataResponse.arrayBuffer();
const decompressed = pako.inflate(compressed, { to: 'string' });
const rawData = JSON.parse(decompressed);

Batch SQL Inserts

Inserting 36K rows one-by-one is slow. We batch 500 at a time:

// Insert in batches of 500
const BATCH = 500;
for (let i = 0; i < properties.length; i += BATCH) {
    const batch = properties.slice(i, i + BATCH);
    const values = batch.map(p => 
        `('${escape(p.address)}', '${p.city}', ${p.price}, ...)`
    ).join(',');
    db.run(`INSERT INTO props VALUES ${values}`);
}
// Result: ~70x faster than row-by-row

Cache Busting

We append today's date to force fresh data daily while allowing same-day caching:

const today = new Date().toISOString().split('T')[0]; // "2025-07-18"
fetch(`ri-sales.json.gz?v=${today}`);

3. Precomputed City Stats

Instead of relying on pre-aggregated summary data, we compute city statistics on-the-fly from raw properties. This is more flexible and self-healing:

// Compute city stats from raw properties
const cityStats = {};
rawData.properties.forEach(p => {
    if (!cityStats[p.city]) {
        cityStats[p.city] = { count: 0, prices: [], landCount: 0 };
    }
    cityStats[p.city].count++;
    if (p.price > 0) cityStats[p.city].prices.push(p.price);
    if (p.propertyType?.includes('Land')) cityStats[p.city].landCount++;
});

// Transform to sorted array with median prices
const cityData = Object.entries(cityStats)
    .map(([city, s]) => ({
        city,
        count: s.count,
        medianPrice: s.prices.sort((a,b) => a-b)[Math.floor(s.prices.length/2)] || 0,
        landCount: s.landCount
    }))
    .sort((a,b) => b.count - a.count)
    .slice(0, 15);

// Store globally for charts
window.computedCityStats = cityStats;
Why this matters: The original code expected summary.cities to be objects with stats, but our data cleanup simplified it to just city names. Computing on-the-fly means charts always work regardless of summary format.

4. Service Workers & Offline Cache

What is a Service Worker?

A service worker is a JavaScript file that runs in the background, separate from your web page. It can intercept network requests, cache resources, and enable offline functionality.

Service Worker Lifecycle
  1. Register - Browser downloads and parses the SW file
  2. Install - SW caches essential files ("precache")
  3. Activate - SW takes control, cleans old caches
  4. Fetch - SW intercepts requests, serves from cache or network

Example Service Worker (sw.js)

const CACHE_NAME = 'ri-real-estate-v1';
const PRECACHE_URLS = [
    '/',
    '/ri-report.html',
    '/ri-results.html',
    '/ri-sales.json.gz',
    'https://cdn.jsdelivr.net/npm/chart.js',
    'https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js'
];

// Install: cache essential files
self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(CACHE_NAME).then(cache => {
            console.log('Precaching app shell');
            return cache.addAll(PRECACHE_URLS);
        })
    );
});

// Activate: clean old caches
self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(keys => Promise.all(
            keys.filter(k => k !== CACHE_NAME)
                .map(k => caches.delete(k))
        ))
    );
});

// Fetch: cache-first for static assets, network-first for data
self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);
    
    // Network-first for data (always try fresh)
    if (url.pathname.includes('.json')) {
        event.respondWith(
            fetch(event.request)
                .then(response => {
                    const clone = response.clone();
                    caches.open(CACHE_NAME).then(c => c.put(event.request, clone));
                    return response;
                })
                .catch(() => caches.match(event.request))
        );
        return;
    }
    
    // Cache-first for static assets
    event.respondWith(
        caches.match(event.request)
            .then(cached => cached || fetch(event.request))
    );
});

Registering the Service Worker

<script>
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('SW registered:', reg.scope))
        .catch(err => console.error('SW failed:', err));
}
</script>
Caching Strategies:
Cache-First: Check cache, fall back to network (fast, may be stale)
Network-First: Try network, fall back to cache (fresh, slower)
Stale-While-Revalidate: Serve cache, update in background (best of both)

5. Pagination System

Our pagination supports dynamic page sizes with a dropdown selector:

let pageSize = 25;  // Default
let currentPage = 0;

function renderPage() {
    const effectiveSize = pageSize === 'all' ? filtered.length : pageSize;
    const start = currentPage * effectiveSize;
    const end = Math.min(start + effectiveSize, filtered.length);
    const pageData = filtered.slice(start, end);
    
    // Render rows...
    
    // Update all pagination controls
    document.querySelectorAll('.page-info').forEach(el => {
        el.textContent = pageSize === 'all' 
            ? `Showing all ${filtered.length} results`
            : `Page ${currentPage + 1} of ${totalPages}`;
    });
}

function changePageSize(val) {
    pageSize = val === 'all' ? 'all' : parseInt(val);
    currentPage = 0;  // Reset to first page
    renderPage();
}

6. Chart.js Integration

We use Chart.js for all visualizations with a consistent dark theme:

const chartOpts = {
    responsive: true,
    maintainAspectRatio: true,
    plugins: {
        legend: { labels: { color: '#94a3b8' } }
    },
    scales: {
        x: { 
            grid: { color: 'rgba(255,255,255,0.05)' }, 
            ticks: { color: '#94a3b8' } 
        },
        y: { 
            grid: { color: 'rgba(255,255,255,0.05)' }, 
            ticks: { color: '#94a3b8' } 
        }
    }
};

// Example: Horizontal bar chart
new Chart(document.getElementById('cityChart'), {
    type: 'bar',
    data: {
        labels: cityData.map(c => c.city),
        datasets: [{
            data: cityData.map(c => c.count),
            backgroundColor: cityData.map((_, i) => 
                `hsl(${250 + i * 7}, 70%, 60%)`  // Gradient colors
            ),
            borderRadius: 6
        }]
    },
    options: { 
        indexAxis: 'y',  // Makes it horizontal
        ...chartOpts, 
        plugins: { legend: { display: false } } 
    }
});

7. Neighborhood Explorer

Haversine Distance Formula

We calculate distances between properties using the Haversine formula (accounts for Earth's curvature):

function getDistance(lat1, lng1, lat2, lng2) {
    const R = 3959; // Earth radius in miles
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLng = (lng2 - lng1) * Math.PI / 180;
    const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
              Math.cos(lat1 * Math.PI / 180) * 
              Math.cos(lat2 * Math.PI / 180) *
              Math.sin(dLng/2) * Math.sin(dLng/2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;  // Distance in miles
}

function getNearbyProperties(lat, lng, radiusMiles) {
    return rawData.properties
        .filter(p => p.lat && p.lng)
        .map(p => ({
            ...p,
            distance: getDistance(lat, lng, p.lat, p.lng)
        }))
        .filter(p => p.distance <= radiusMiles)
        .sort((a, b) => a.distance - b.distance);
}

Fallback for Missing Coordinates

When a property lacks lat/lng, we fall back to city-wide comps:

function showNeighborhood(idx) {
    const property = window.dealsData[idx];
    
    if (!property.lat || !property.lng) {
        // Fallback: show city-wide data instead of error
        showCityComps(property.city, 
            property.address + ' (No coordinates - showing city-wide data)');
        return;
    }
    
    // Normal neighborhood view...
}

8. Future Improvements

🔮 Lazy Loading

Only load visible table rows, fetch more on scroll (IntersectionObserver).

📊 IndexedDB

Store property data in browser's IndexedDB for instant offline access.

🗺️ Map Integration

Add Leaflet.js map with property markers and clustering.

Auto-Update Cron

Scheduled scraper to refresh data daily via GitHub Actions.

Advanced: Web Workers for Heavy Computation

// main.js
const worker = new Worker('compute-worker.js');
worker.postMessage({ properties: rawData.properties });
worker.onmessage = (e) => {
    const { cityStats, monthlyTrends } = e.data;
    initCharts(cityStats, monthlyTrends);
};

// compute-worker.js
self.onmessage = (e) => {
    const { properties } = e.data;
    // Heavy computation off main thread...
    const cityStats = computeCityStats(properties);
    self.postMessage({ cityStats });
};
🎉 You made it!
This documentation covers the key technical decisions behind the RI Real Estate Hub. The codebase is open for exploration at GitHub.

Last updated: July 2025 • Back to ReportAIBridges.org