[object Object]

← back to Homesonspec

homesonspec: Cycle 2 — map viewport-cull+grid-cluster (no 70k freeze), fix save-bridge /home->/homes URL + robust price/loc capture, add /privacy page (local; prod deploy gated)

572ac8f76267c5615aea1708328d82b48a7e2cfb · 2026-08-06 13:27:55 -0700 · Steve

Files touched

Diff

commit 572ac8f76267c5615aea1708328d82b48a7e2cfb
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 13:27:55 2026 -0700

    homesonspec: Cycle 2 — map viewport-cull+grid-cluster (no 70k freeze), fix save-bridge /home->/homes URL + robust price/loc capture, add /privacy page (local; prod deploy gated)
---
 apps/mobile/app/(tabs)/browse.tsx |  26 ++++--
 apps/mobile/app/(tabs)/map.tsx    | 175 ++++++++++++++++++++++++++++++++------
 apps/web/src/app/privacy/page.tsx | 111 ++++++++++++++++++++++++
 3 files changed, 277 insertions(+), 35 deletions(-)

diff --git a/apps/mobile/app/(tabs)/browse.tsx b/apps/mobile/app/(tabs)/browse.tsx
index a3d5c74e..638b503d 100644
--- a/apps/mobile/app/(tabs)/browse.tsx
+++ b/apps/mobile/app/(tabs)/browse.tsx
@@ -55,18 +55,30 @@ const INJECTED_CSS = `
 
 /**
  * Injected JS that posts a message to RN when the user taps a home-detail page.
- * We scan for the h1 title, price, and location from the PDP page.
- * Best-effort; no-op if selectors don't match.
+ * Verified against the LIVE PDP (/homes/:id, Cycle 2): the h1 holds the home's
+ * street address (present in SSR). There are NO data-price/data-location
+ * attributes on the page, so those are best-effort text-scan fallbacks:
+ *   price    -> first "$NNN,NNN" in the visible text, stored digits-only
+ *   location -> first "City, ST" pattern
+ * Best-effort; the save still works on id + title alone if these miss.
+ * FOLLOW-UP (Cycle 3): enrich price/city/status by home id from /api/map
+ * instead of scraping the DOM — the app already has that dataset.
  */
 const INJECTED_SAVE_BRIDGE = `
 (function() {
   try {
+    var h1 = document.querySelector('h1');
+    var bodyText = (document.body && document.body.innerText) || '';
+    var priceEl = document.querySelector('[data-price]');
+    var priceMatch = bodyText.match(/\\$[0-9]{1,3}(?:,[0-9]{3})+/);
+    var locEl = document.querySelector('[data-location]');
+    var locMatch = bodyText.match(/[A-Za-z .'-]{2,},\\s*[A-Z]{2}\\b/);
     window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify({
       type: 'PAGE_CHANGE',
       url: window.location.href,
-      title: (document.querySelector('h1') || {}).innerText || '',
-      price: (document.querySelector('[data-price]') || {}).dataset?.price || '',
-      location: (document.querySelector('[data-location]') || {}).dataset?.location || '',
+      title: (h1 && h1.innerText) || '',
+      price: (priceEl && priceEl.dataset && priceEl.dataset.price) || (priceMatch ? priceMatch[0].replace(/[^0-9]/g, '') : ''),
+      location: (locEl && locEl.dataset && locEl.dataset.location) || (locMatch ? locMatch[0] : ''),
     }));
   } catch(e) {}
   true;
@@ -86,7 +98,7 @@ export default function BrowseTab() {
   const [error, setError] = useState<string | null>(null);
   const [currentPage, setCurrentPage] = useState<PageInfo | null>(null);
 
-  const isHomePage = currentPage?.url?.match(/\/home\/[a-zA-Z0-9]+/);
+  const isHomePage = currentPage?.url?.match(/\/homes\/[a-zA-Z0-9]+/);
 
   function handleMessage(event: { nativeEvent: { data: string } }) {
     try {
@@ -101,7 +113,7 @@ export default function BrowseTab() {
 
   function handleSaveHome() {
     if (!currentPage) return;
-    const idMatch = currentPage.url.match(/\/home\/([a-zA-Z0-9]+)/);
+    const idMatch = currentPage.url.match(/\/homes\/([a-zA-Z0-9]+)/);
     const id = idMatch ? idMatch[1] : currentPage.url;
 
     saveHome({
diff --git a/apps/mobile/app/(tabs)/map.tsx b/apps/mobile/app/(tabs)/map.tsx
index 60f85271..eacc0b1e 100644
--- a/apps/mobile/app/(tabs)/map.tsx
+++ b/apps/mobile/app/(tabs)/map.tsx
@@ -4,18 +4,22 @@
  * Native react-native-maps rendering ~70k+ new-construction homes from
  * the live GET /api/map endpoint. Color-codes by construction status.
  *
+ * PERFORMANCE (Cycle 2, DTD verdict C — viewport-cull THEN cluster):
+ * Rendering all ~70k <Marker> at once freezes the JS thread on device.
+ * Instead we (1) cull to the padded visible bbox on each region SETTLE
+ * (onRegionChangeComplete — not per frame), then (2) grid-cluster the
+ * culled subset into a fixed ~14x14 grid, so the native map never mounts
+ * more than ~196 annotations at any zoom. A cell with a single home renders
+ * as a real pin; a cell with many renders as a count bubble that zooms in
+ * on tap. Zero new dependency (build-safe). A bbox padding buffer prevents
+ * edge pop-in (the risk every panelist named). supercluster is the future
+ * accuracy upgrade if density UX needs it.
+ *
  * Endpoint wired: GET https://homesonspec.com/api/map
- * Response shape: { markers: MapMarker[], builders: [...], total: number }
  * MapMarker: { i, la, lo, p, b, s, bl, c }
- *   la = latitude, lo = longitude, p = price, b = beds,
- *   s = MOVE_IN_READY | UNDER_CONSTRUCTION | PLANNED, bl = builder slug, c = city
- *
- * Native value: MapKit (iOS) — not a browser-embedded Leaflet/Mapbox page.
- * 70k+ markers, color-coded, filterable by status, with native bottom-sheet
- * callouts. No equivalent on the website for anonymous visitors.
  */
 
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import {
   View,
   Text,
@@ -24,7 +28,6 @@ import {
   StyleSheet,
   SafeAreaView,
   Linking,
-  Platform,
 } from 'react-native';
 import MapView, { Marker, Region, Callout } from 'react-native-maps';
 import { fetchMapMarkers, type MapMarker, homeDetailUrl } from '../../lib/api';
@@ -46,6 +49,18 @@ const DEFAULT_REGION: Region = {
   longitudeDelta: 40,
 };
 
+// Clustering grid resolution (cells across the visible span) and edge padding.
+const GRID_CELLS = 14;
+const BBOX_PAD = 0.2; // 20% padding around the visible region → no edge pop-in
+
+type Cluster = {
+  key: string;
+  count: number;
+  latitude: number;
+  longitude: number;
+  sample: MapMarker;
+};
+
 function formatPrice(price: number | null): string {
   if (price === null) return '—';
   if (price >= 1_000_000) return `$${(price / 1_000_000).toFixed(1)}M`;
@@ -58,6 +73,57 @@ function statusLabel(s: string): string {
   return 'Under Construction';
 }
 
+/**
+ * Viewport-cull the full marker set to the padded visible bbox, then bucket
+ * the survivors into a fixed lat/lng grid. Returns at most ~GRID_CELLS^2 items.
+ */
+function clusterMarkers(
+  all: MapMarker[],
+  region: Region,
+  filterStatus: string | null,
+): Cluster[] {
+  const { latitude, longitude, latitudeDelta, longitudeDelta } = region;
+  const padLat = latitudeDelta * BBOX_PAD;
+  const padLng = longitudeDelta * BBOX_PAD;
+  const minLat = latitude - latitudeDelta / 2 - padLat;
+  const maxLat = latitude + latitudeDelta / 2 + padLat;
+  const minLng = longitude - longitudeDelta / 2 - padLng;
+  const maxLng = longitude + longitudeDelta / 2 + padLng;
+
+  const cellLat = latitudeDelta / GRID_CELLS || 0.01;
+  const cellLng = longitudeDelta / GRID_CELLS || 0.01;
+
+  const cells = new Map<string, { count: number; sumLa: number; sumLo: number; sample: MapMarker }>();
+  for (let idx = 0; idx < all.length; idx++) {
+    const m = all[idx];
+    if (filterStatus && m.s !== filterStatus) continue;
+    if (m.la < minLat || m.la > maxLat || m.lo < minLng || m.lo > maxLng) continue;
+    const gx = Math.floor(m.lo / cellLng);
+    const gy = Math.floor(m.la / cellLat);
+    const key = `${gx}:${gy}`;
+    let c = cells.get(key);
+    if (!c) {
+      c = { count: 0, sumLa: 0, sumLo: 0, sample: m };
+      cells.set(key, c);
+    }
+    c.count++;
+    c.sumLa += m.la;
+    c.sumLo += m.lo;
+  }
+
+  const out: Cluster[] = [];
+  cells.forEach((c, key) => {
+    out.push({
+      key,
+      count: c.count,
+      latitude: c.sumLa / c.count,
+      longitude: c.sumLo / c.count,
+      sample: c.sample,
+    });
+  });
+  return out;
+}
+
 export default function MapTab() {
   const mapRef = useRef<MapView>(null);
   const [markers, setMarkers] = useState<MapMarker[]>([]);
@@ -66,6 +132,7 @@ export default function MapTab() {
   const [total, setTotal] = useState(0);
   const [filterStatus, setFilterStatus] = useState<string | null>(null);
   const [selected, setSelected] = useState<MapMarker | null>(null);
+  const [region, setRegion] = useState<Region>(DEFAULT_REGION);
 
   const load = useCallback(async () => {
     setLoading(true);
@@ -85,12 +152,31 @@ export default function MapTab() {
     load();
   }, [load]);
 
-  const visibleMarkers = filterStatus
-    ? markers.filter((m) => m.s === filterStatus)
-    : markers;
+  // Recompute clusters only when the data, filter, or SETTLED region changes.
+  const clusters = useMemo(
+    () => clusterMarkers(markers, region, filterStatus),
+    [markers, region, filterStatus],
+  );
 
   const filterKeys: Array<string | null> = ['MOVE_IN_READY', 'UNDER_CONSTRUCTION', 'PLANNED', null];
 
+  function handleClusterPress(c: Cluster) {
+    if (c.count === 1) {
+      setSelected(c.sample);
+      return;
+    }
+    // Zoom into the cluster: quarter the deltas, centered on the cluster.
+    mapRef.current?.animateToRegion(
+      {
+        latitude: c.latitude,
+        longitude: c.longitude,
+        latitudeDelta: Math.max(region.latitudeDelta / 4, 0.02),
+        longitudeDelta: Math.max(region.longitudeDelta / 4, 0.02),
+      },
+      350,
+    );
+  }
+
   return (
     <SafeAreaView style={styles.container}>
       {/* Legend / filter strip */}
@@ -130,26 +216,42 @@ export default function MapTab() {
           ref={mapRef}
           style={styles.map}
           initialRegion={DEFAULT_REGION}
+          onRegionChangeComplete={setRegion}
           showsUserLocation
           showsCompass
         >
-          {visibleMarkers.map((m) => (
-            <Marker
-              key={m.i}
-              coordinate={{ latitude: m.la, longitude: m.lo }}
-              pinColor={STATUS_COLORS[m.s] ?? BRAND}
-              onPress={() => setSelected(m)}
-              tracksViewChanges={false}
-            >
-              <Callout tooltip onPress={() => setSelected(m)}>
-                <View style={styles.callout}>
-                  <Text style={styles.calloutPrice}>{formatPrice(m.p)}</Text>
-                  <Text style={styles.calloutCity}>{m.c}</Text>
-                  <Text style={styles.calloutStatus}>{statusLabel(m.s)}</Text>
+          {clusters.map((c) =>
+            c.count === 1 ? (
+              <Marker
+                key={c.key}
+                coordinate={{ latitude: c.latitude, longitude: c.longitude }}
+                pinColor={STATUS_COLORS[c.sample.s] ?? BRAND}
+                onPress={() => setSelected(c.sample)}
+                tracksViewChanges={false}
+              >
+                <Callout tooltip onPress={() => setSelected(c.sample)}>
+                  <View style={styles.callout}>
+                    <Text style={styles.calloutPrice}>{formatPrice(c.sample.p)}</Text>
+                    <Text style={styles.calloutCity}>{c.sample.c}</Text>
+                    <Text style={styles.calloutStatus}>{statusLabel(c.sample.s)}</Text>
+                  </View>
+                </Callout>
+              </Marker>
+            ) : (
+              <Marker
+                key={c.key}
+                coordinate={{ latitude: c.latitude, longitude: c.longitude }}
+                onPress={() => handleClusterPress(c)}
+                tracksViewChanges={false}
+              >
+                <View style={styles.cluster}>
+                  <Text style={styles.clusterText}>
+                    {c.count >= 1000 ? `${Math.round(c.count / 1000)}k` : c.count}
+                  </Text>
                 </View>
-              </Callout>
-            </Marker>
-          ))}
+              </Marker>
+            ),
+          )}
         </MapView>
       )}
 
@@ -232,6 +334,23 @@ const styles = StyleSheet.create({
     backgroundColor: 'rgba(255,255,255,0.75)',
   },
   loadingText: { marginTop: 8, fontSize: 13, color: '#6b7280' },
+  cluster: {
+    backgroundColor: BRAND,
+    minWidth: 34,
+    height: 34,
+    borderRadius: 17,
+    paddingHorizontal: 8,
+    justifyContent: 'center',
+    alignItems: 'center',
+    borderWidth: 2,
+    borderColor: '#fff',
+    shadowColor: '#000',
+    shadowOffset: { width: 0, height: 1 },
+    shadowOpacity: 0.25,
+    shadowRadius: 2,
+    elevation: 4,
+  },
+  clusterText: { color: '#fff', fontWeight: '800', fontSize: 12 },
   callout: {
     backgroundColor: '#fff',
     borderRadius: 8,
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
new file mode 100644
index 00000000..9fc72134
--- /dev/null
+++ b/apps/web/src/app/privacy/page.tsx
@@ -0,0 +1,111 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+  title: "Privacy Policy — Homes on Spec",
+  description:
+    "How Homes on Spec handles data across the website and the Homes on Spec iOS app.",
+};
+
+const UPDATED = "August 6, 2026";
+
+export default function PrivacyPage() {
+  return (
+    <main className="mx-auto max-w-3xl px-5 py-12">
+      <h1 className="font-display text-3xl font-semibold sm:text-4xl">
+        Privacy Policy
+      </h1>
+      <p className="mt-2 text-sm text-gray-500">Last updated: {UPDATED}</p>
+
+      <div className="mt-8 space-y-6 text-[15px] leading-relaxed text-gray-700">
+        <p>
+          Homes on Spec (&ldquo;we&rdquo;, &ldquo;us&rdquo;) operates the website
+          homesonspec.com and the Homes on Spec iOS app. This policy explains what
+          we collect and how we use it. Our guiding principle is to collect as
+          little as possible: the app requires no account and no sign-in to browse,
+          search, save homes, or view the map.
+        </p>
+
+        <section>
+          <h2 className="font-display text-xl font-semibold text-gray-900">
+            Information we do NOT collect
+          </h2>
+          <ul className="mt-3 list-disc space-y-1 pl-5">
+            <li>No account, name, email, or password is required to use the app.</li>
+            <li>We do not sell, rent, or share your personal information.</li>
+            <li>We do not track you across other apps or websites for advertising.</li>
+          </ul>
+        </section>
+
+        <section>
+          <h2 className="font-display text-xl font-semibold text-gray-900">
+            Information handled on your device
+          </h2>
+          <ul className="mt-3 list-disc space-y-1 pl-5">
+            <li>
+              <strong>Saved homes.</strong> When you save a home, it is stored
+              locally on your device only. It is not sent to our servers and is
+              removed when you unsave it or delete the app.
+            </li>
+            <li>
+              <strong>Notification preferences &amp; push token.</strong> If you
+              opt in to alerts, your notification preference and the device push
+              token are stored locally on your device. New-listing push delivery is
+              a roadmap feature and is not yet active.
+            </li>
+            <li>
+              <strong>Location.</strong> If you grant location access, it is used
+              only to center the in-app map on your area. Your location is not
+              stored or transmitted to us.
+            </li>
+          </ul>
+        </section>
+
+        <section>
+          <h2 className="font-display text-xl font-semibold text-gray-900">
+            Listing data
+          </h2>
+          <p className="mt-3">
+            The homes, communities, plans, and prices shown are aggregated from
+            publicly available builder sources. This is property information, not
+            information about you.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-xl font-semibold text-gray-900">
+            Website analytics
+          </h2>
+          <p className="mt-3">
+            On homesonspec.com we may use privacy-respecting, aggregate analytics to
+            understand traffic and improve the site. These do not identify you
+            personally.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-xl font-semibold text-gray-900">
+            Children&rsquo;s privacy
+          </h2>
+          <p className="mt-3">
+            Homes on Spec is not directed to children under 13 and we do not
+            knowingly collect information from them.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-xl font-semibold text-gray-900">
+            Contact
+          </h2>
+          <p className="mt-3">
+            Questions about this policy? Reach us through the{" "}
+            <a className="text-blue-700 underline" href="/contact">
+              contact page
+            </a>
+            . We may update this policy from time to time; the date above reflects
+            the latest revision.
+          </p>
+        </section>
+      </div>
+    </main>
+  );
+}

← d67c56fa homesonspec/mobile: make Alerts tab honest (no fake delivery  ·  back to Homesonspec  ·  DEPLOY.md: document deploy-web.sh as the sanctioned safe web f8ba090b →