← 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
M apps/mobile/app/(tabs)/browse.tsxM apps/mobile/app/(tabs)/map.tsxA apps/web/src/app/privacy/page.tsx
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 (“we”, “us”) 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 & 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’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 →