← back to Commercialrealestate

docs/recities-city-scoping-spec.md

121 lines

# Spec: per-city scoping for the `*.crcp.agentabrams.com` fleet

**Goal:** when a visitor lands on `<city>.crcp.agentabrams.com`, the grid shows only that
city's listings (today every subdomain serves the identical, unscoped app). `recities.` stays
the launcher hub; base `crcp.` stays unscoped ("all markets").

**Status:** spec only — not built. Author: main-loop, 2026-08-20 (TK: recities-standup).

---

## 1. Architecture reality (measured, not assumed)

- The main grid is **100% front-end**: `index.html` does `fetch('data/ranked.json')` → `DATA` →
  `DATA.ranked` → `recompute()`/`render()`. Prod (Kamatera) has **no Postgres**; it serves the
  static `data/ranked.json` snapshot. **So scoping is a front-end filter, NOT a serve.js change.**
  (My earlier "larger serve.js change" note was wrong — corrected here.)
- `ranked.json`: **11,919 listings**, `meta.market = "Los Angeles County, CA"` but the data is
  effectively **national** (top cities: Los Angeles 1255, Las Vegas 357, Houston 265, Miami 253,
  Dallas 126, NYC 106…). Each property has a `.city` string (mixed case, some dupes like
  `Reseda`/`RESEDA`).
- Host→city mapping already exists as the hub's `LABELS`/`SLUGS` map (`recities.html`).

## 2. THE CRUX — data coverage is thin and exact-match under-counts

Exact city-name match of the 50 fleet slugs against `ranked.json`:

- **Only 23/50 subdomains have ANY listings.** 27 render empty.
- Populated but thin: Pasadena 137, Santa Monica 88, Beverly Hills 27, Malibu 23, Sherman Oaks 20,
  Altadena 13, Greenville 13, Georgetown 11, Studio City 8, Redding 8 … then a long tail of
  Somis 1, SJC 2, Ukiah 2, South Lake Tahoe 2, Somerset 2.
- **False zeros:** `Encino` = 0 despite being prime SFV — its addresses are tagged `Los Angeles`.
  Exact city match systematically under-counts neighborhoods that the source lumps into "Los Angeles".
- The 27 empties are mostly small NorCal towns (Weed, Ukiah, Chester, Colfax…) provisioned as
  subdomains ahead of any inventory.

**Implication:** the hard part is not the filter — it's honesty about coverage. A naive
"filter by city" ships 27 dead-empty city pages and mislabels Encino/Topanga as having none.

## 3. Recommended design (phased)

### Phase 1 — front-end hostname scope + honest hub (small, ship first)
1. **`index.html`, in the `loadRanked().then` boot (~line 2307), before first `render()`:**
   ```js
   const CITY = cityFromHost(location.hostname);      // null for crcp./recities./localhost
   if (CITY) {
     DATA.ranked = DATA.ranked.filter(p => sameCity(p.city, CITY));
     DATA.meta.market = CITY;                          // sub-header shows the scoped market
   }
   ```
   - `cityFromHost`: take label-0 of the host; if it's `crcp`/`recities`/`www`/an IP → return null
     (unscoped). Else map slug→city via the shared `LABELS` table (single source of truth — extract
     it to `public/crcp-cities.js` and `<script>`-include it in both `recities.html` and `index.html`).
   - `sameCity(a,b)`: normalize `toLowerCase().replace(/[^a-z]/g,'')` both sides, compare equal.
2. **Scope banner** (top of grid): `Showing {City} · {n} listings · [View all markets →]` where the
   link clears scope (`https://crcp.agentabrams.com/`). Non-negotiable escape hatch.
3. **Empty state** when `n===0`: `No CRCP listings in {City} yet · [Browse all markets →]` — never a
   blank grid.
4. **Hub honesty (`recities.html`):** badge each city in the dropdown/grid with its live count and
   **grey out / sort-to-bottom the zero-count cities** (fetch `data/ranked.json` once, compute counts
   client-side). So the launcher never sends someone into a dead city unknowingly.
   - Optional toggle: "Hide empty markets" (default ON) → dropdown shows the ~23 real ones.

**Effort:** ~1 file (`index.html`) + extract `crcp-cities.js` + hub badge logic. Front-end only →
deploys via `push-frontend-kamatera.sh` (**no serve.js, no app restart**). ~half a day.

### Phase 2 — fix the false zeros (medium, only if Steve wants marquee cities accurate)
Exact-match misses neighborhood-of-LA cities. Add a **ZIP/alias table** for the marquee SFV/Westside
subdomains so `Encino`, `Topanga`, `Bel-Air`, `Pacific Palisades` match by ZIP prefix or alias:
```js
ALIASES = { encino:{zips:['91316','91436'], names:['Encino']},
            topanga:{zips:['90290']}, belair:{zips:['90077'], names:['Bel Air','Bel-Air']}, … }
sameCity() also matches p.zip against the slug's zips.
```
Requires `ranked.json` rows to carry `zip` (they do). ~1 day incl. curating ZIPs for the ~10 cities
that matter.

### Phase 3 — server-side scope (only if SEO/perf ever matters; NOT recommended now)
Pre-split `ranked.json` into `data/by-city/<slug>.json` at snapshot-build time and have serve.js pick
by host. Adds a build step + a serve.js host-read. Unnecessary for an auth-gated internal tool where
the whole snapshot already loads fine client-side.

## 4. Decisions for Steve
- **A. Ship Phase 1 alone?** (exact-match + honest empty-handling + count-badged hub) — recommended.
- **B. Include Phase 2** ZIP-aliases so Encino/Topanga/Bel-Air aren't false-zero? (recommended for
  the SFV cities Steve cares about).
- **C. Empty subdomains:** hide from hub (default) · show greyed with "no inventory" · or **de-provision**
  the 27 dead NorCal subdomains entirely (DNS+vhost cleanup) so the fleet == real coverage.
- **D. Scope source of truth:** city-name (Phase 1) is quick but coarse; ZIP (Phase 2) is accurate.

## 4b. PHASE 2 — BUILT, MEASURED, AND FOUND UNNECESSARY (2026-08-20)

Rather than ship ZIP-alias code on assumption, I geocoded the real data first (the right order):

- **Built `scripts/geocode-ranked.js`** — enriches `ranked.json` with `zip` + `lat`/`lng` via the
  **FREE US Census batch geocoder** (no key, $0, 10k/batch). Additive + idempotent (fills only rows
  missing `zip`), backup-first (`ranked.json.bak-geocode`).
- **Ran it full:** matched **9,286 / 11,876** un-geocoded rows (**78.2%**, $0). `ranked.json` now
  carries real ZIPs on 9,329 rows and lat/lng on 9,286 (durable — also unlocks future map features).
- **Wired into the pipeline:** `analyze.js` now runs `geocode-ranked.js` after each regen (next to
  `enrich-provenance.js`), so the enrichment survives every rebuild. Non-fatal on geocoder outage.

**The verdict — ZIP scoping recovers ZERO extra listings:**
- The marquee "false-zero" cities (Encino, Topanga, Bel-Air, Tarzana, Studio City) are STILL 0 after
  geocoding. The 1,051 geocoded "Los Angeles" rows sit in central/east/south-LA ZIPs (90026, 90048,
  90029, 90004…) — **not one** in an SFV/Westside neighborhood ZIP. The earlier "hidden inside Los
  Angeles" hypothesis is **disproven**: those neighborhoods genuinely have no inventory in this catalog.
- Across 14 tested fleet cities, `name-match` and `name-OR-zip union` give the **identical count**
  (339 = 339, net gain **0**). City-name matching already captures 100% of recoverable inventory.

**Conclusion:** do NOT wire ZIP scoping into the front-end — it adds code + a ZIP table for zero
listing-count change. Phase 1 (city-name) is the complete scoping solution. The geocode enrichment is
kept anyway for its independent value (accurate ZIPs + lat/lng for mapping), not for scoping.

**What would actually unlock the empty cities:** more inventory (a broader scrape covering the SFV /
NorCal towns), not smarter matching. The fleet was provisioned ahead of the catalog.

## 5. Non-goals / guardrails
- No serve.js change in Phase 1/2 → no prod restart, blast radius = `index.html` + hub.
- Keep base `crcp.` and `recities.` unscoped.
- Don't fabricate a city for a listing; unknown/blank `p.city` never matches a scoped host.