[object Object]

← back to Nationalrealestate

USRE: geo-search now resolves ZIP (zip_county crosswalk) + street address (Census geocoder); rolled site-wide (pill on map/content, inline in grid top-bar) (v0.13.0)

e08091bee055bfe4bfb7cb370af44e1ae907ca77 · 2026-07-26 21:09:05 -0700 · Steve

Files touched

Diff

commit e08091bee055bfe4bfb7cb370af44e1ae907ca77
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 21:09:05 2026 -0700

    USRE: geo-search now resolves ZIP (zip_county crosswalk) + street address (Census geocoder); rolled site-wide (pill on map/content, inline in grid top-bar) (v0.13.0)
---
 package-lock.json               |  4 +--
 package.json                    |  5 ++--
 public/admin.html               |  2 ++
 public/brokers.html             |  2 ++
 public/commercial-brokers.html  |  2 ++
 public/commercial-feed.html     |  2 ++
 public/commercial-property.html |  2 ++
 public/commercial.html          |  2 ++
 public/compare.html             |  2 ++
 public/geo-search.css           | 48 ++++++++++++++++++++-----------
 public/geo-search.js            | 62 ++++++++++++++++++++++++++---------------
 public/listings.html            |  2 ++
 public/market.html              |  2 ++
 public/markets.html             |  2 ++
 public/property.html            |  2 ++
 public/sources.html             |  2 ++
 public/watchlist.html           |  2 ++
 src/ingest/zip_county.ts        | 50 +++++++++++++++++++++++++++++++++
 src/server/index.ts             | 32 +++++++++++++++++++++
 19 files changed, 183 insertions(+), 44 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index d793155..68254b3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "nationalrealestate",
-  "version": "0.12.0",
+  "version": "0.13.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "nationalrealestate",
-      "version": "0.12.0",
+      "version": "0.13.0",
       "dependencies": {
         "better-sqlite3": "^13.0.1",
         "dotenv": "^16.4.5",
diff --git a/package.json b/package.json
index d3eefc9..35be955 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "nationalrealestate",
-  "version": "0.12.0",
+  "version": "0.13.0",
   "private": true,
   "type": "module",
   "description": "USRealEstate — national U.S. residential market explorer. Free-data v1 (Redfin Data Center, Zillow Research, Census ACS, FHFA). Internal analyst tool behind basic auth.",
@@ -26,7 +26,8 @@
     "ingest:commercial": "tsx src/ingest/commercial/engine.ts",
     "ingest:commercial-briefs": "tsx src/ingest/commercial/briefs.ts",
     "ingest:places": "tsx src/ingest/places/seed.ts",
-    "loop": "tsx src/jobs/hourly_loop.ts"
+    "loop": "tsx src/jobs/hourly_loop.ts",
+    "ingest:zipcounty": "tsx src/ingest/zip_county.ts"
   },
   "dependencies": {
     "better-sqlite3": "^13.0.1",
diff --git a/public/admin.html b/public/admin.html
index 8459c79..a389bb5 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -35,6 +35,7 @@
   .empty{color:var(--muted);padding:18px;text-align:center;}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -104,5 +105,6 @@ loadRuns();
 setInterval(loadRuns, 30000);
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/brokers.html b/public/brokers.html
index 7489d64..74ba0ce 100644
--- a/public/brokers.html
+++ b/public/brokers.html
@@ -54,6 +54,7 @@
   .usre-fd .fd-brokers span{color:var(--muted);font-size:12px;white-space:nowrap;}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -244,5 +245,6 @@ loadStats();
 loadPage();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/commercial-brokers.html b/public/commercial-brokers.html
index 187d8ef..4a16f44 100644
--- a/public/commercial-brokers.html
+++ b/public/commercial-brokers.html
@@ -32,6 +32,7 @@
   #loading{padding:40px;text-align:center;color:var(--muted);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -98,5 +99,6 @@ $('#q').oninput=()=>{state.q=$('#q').value;clearTimeout(window._t);window._t=set
 load();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/commercial-feed.html b/public/commercial-feed.html
index 4b340f7..94bebfa 100644
--- a/public/commercial-feed.html
+++ b/public/commercial-feed.html
@@ -36,6 +36,7 @@
   #loading{padding:40px;text-align:center;color:var(--muted);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -114,5 +115,6 @@ async function load(){
 (async()=>{ await loadTypes(); await loadCities(); await load(); })();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/commercial-property.html b/public/commercial-property.html
index f47933c..b54b33e 100644
--- a/public/commercial-property.html
+++ b/public/commercial-property.html
@@ -31,6 +31,7 @@
   .back{font-size:13px;color:var(--muted);text-decoration:none;} .back:hover{color:var(--gold);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -86,5 +87,6 @@ function dstr(d){return d?new Date(d).toLocaleDateString(undefined,{year:'numeri
 })();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/commercial.html b/public/commercial.html
index 0cac07b..4f06439 100644
--- a/public/commercial.html
+++ b/public/commercial.html
@@ -41,6 +41,7 @@
   #loading{padding:50px;text-align:center;color:var(--muted);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -133,5 +134,6 @@ $('#sort').onchange=()=>{state.sort=$('#sort').value;localStorage.setItem('cre_s
 (async()=>{ await loadTypes(); await loadCities(); await load(); })();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/compare.html b/public/compare.html
index ea3daa5..2c57142 100644
--- a/public/compare.html
+++ b/public/compare.html
@@ -38,6 +38,7 @@
   .empty{color:var(--muted);padding:50px;text-align:center;}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -196,5 +197,6 @@ async function render(){
 loadRegions().then(render);
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/geo-search.css b/public/geo-search.css
index a8f3059..3b5eac7 100644
--- a/public/geo-search.css
+++ b/public/geo-search.css
@@ -1,28 +1,42 @@
-/* geo-search.css — top-center location search pill (pairs with geo-search.js).
- * Inherits the host page's --bg/--panel/--line/--fg/--muted/--gold CSS vars. */
-#geoSearch{position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:1100;width:min(440px,86vw);}
-#geoSearchBox{display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;
-  background:rgba(17,21,29,.96);border:1px solid var(--line,#222936);border-radius:22px;
-  backdrop-filter:blur(6px);box-shadow:0 2px 14px rgba(0,0,0,.5);}
-#geoSearchBox:focus-within{border-color:var(--gold,#c8a24b);}
-#geoSearchIc{color:var(--gold,#c8a24b);font-size:16px;line-height:1;}
-#geoSearchInput{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--fg,#e8ecf2);
+/* geo-search.css — universal location search (pairs with geo-search.js).
+ * Two modes: .geo-pill (fixed top-center, on map/content pages) and
+ * .geo-inline (sits inside the grid's .cg-top bar). Inherits host --bg/--line/--fg/--muted/--gold. */
+
+/* shared box */
+.geo-box{display:flex;align-items:center;gap:8px;height:40px;padding:0 12px;
+  background:rgba(17,21,29,.96);border:1px solid var(--line,#222936);border-radius:22px;}
+.geo-box:focus-within{border-color:var(--gold,#c8a24b);}
+.geo-ic{color:var(--gold,#c8a24b);font-size:16px;line-height:1;}
+.geo-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--fg,#e8ecf2);
   font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;}
-#geoSearchInput::placeholder{color:var(--muted,#8a93a3);}
-#geoSearchClear{background:none;border:0;color:var(--muted,#8a93a3);cursor:pointer;font-size:13px;padding:2px 4px;}
-#geoSearchClear:hover{color:var(--fg,#e8ecf2);}
-#geoSearchResults{display:none;margin-top:6px;max-height:60vh;overflow-y:auto;
+.geo-input::placeholder{color:var(--muted,#8a93a3);}
+.geo-clear{background:none;border:0;color:var(--muted,#8a93a3);cursor:pointer;font-size:13px;padding:2px 4px;}
+.geo-clear:hover{color:var(--fg,#e8ecf2);}
+
+/* results dropdown */
+.geo-results{display:none;max-height:60vh;overflow-y:auto;z-index:1200;
   background:rgba(17,21,29,.98);border:1px solid var(--line,#222936);border-radius:12px;
   box-shadow:0 8px 24px rgba(0,0,0,.55);}
-#geoSearchResults.open{display:block;}
+.geo-results.open{display:block;}
 .geo-item{display:flex;align-items:center;gap:10px;padding:10px 14px;text-decoration:none;
   color:var(--fg,#e8ecf2);border-bottom:1px solid var(--line,#222936);}
 .geo-item:last-child{border-bottom:0;}
 .geo-item:hover,.geo-item.active{background:rgba(200,162,75,.12);}
-.geo-name{flex:1;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.geo-name{flex:1;min-width:0;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.geo-subline{display:block;font-size:11px;color:var(--muted,#8a93a3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
 .geo-state{font-size:11px;color:var(--muted,#8a93a3);font-weight:700;font-variant-numeric:tabular-nums;}
 .geo-badge{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--gold,#c8a24b);
   border:1px solid var(--line,#222936);border-radius:10px;padding:2px 7px;white-space:nowrap;}
 .geo-empty{padding:12px 14px;color:var(--muted,#8a93a3);font-size:13px;line-height:1.5;}
-/* mobile: clear the fixed upper-left hamburger, don't center under it */
-@media(max-width:820px){#geoSearch{left:64px;right:12px;width:auto;transform:none;}}
+
+/* ── pill mode: fixed top-center (map + content pages) ── */
+.geo-pill{position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:1100;width:min(440px,86vw);}
+.geo-pill .geo-box{height:44px;backdrop-filter:blur(6px);box-shadow:0 2px 14px rgba(0,0,0,.5);}
+.geo-pill .geo-results{margin-top:6px;}
+@media(max-width:820px){.geo-pill{left:64px;right:12px;width:auto;transform:none;}} /* clear the hamburger */
+
+/* ── inline mode: first control inside the grid's .cg-top bar ── */
+.geo-inline{position:relative;flex:0 0 auto;}
+.geo-inline .geo-box{width:260px;max-width:42vw;background:var(--cg-card,#161b22);}
+.geo-inline .geo-results{position:absolute;top:calc(100% + 6px);left:0;min-width:340px;max-width:80vw;}
+@media(max-width:820px){.geo-inline .geo-box{width:180px;}}
diff --git a/public/geo-search.js b/public/geo-search.js
index b91ed06..5038f81 100644
--- a/public/geo-search.js
+++ b/public/geo-search.js
@@ -1,40 +1,42 @@
-/* geo-search.js — top-center location search. Type a county / city / state,
- * get a typeahead of markets (via /api/geo-search), and jump to that market page.
- * National coverage from the region table. Include on any page + a matching CSS link. */
+/* geo-search.js — universal top location search. Type an address / ZIP / city / county /
+ * state, get a typeahead (via /api/geo-search), and jump to that market page.
+ * Mounts two ways so it fits every page:
+ *   - grid pages (have .cg-shell): INLINE as the first control in the grid's .cg-top bar
+ *   - everything else: a fixed top-center pill
+ * Include on any page + the matching geo-search.css. */
 (function () {
-  function init() {
-    if (document.getElementById('geoSearch')) return;
-    const wrap = document.createElement('div'); wrap.id = 'geoSearch';
-    wrap.innerHTML =
-      '<div id="geoSearchBox"><span id="geoSearchIc">⌕</span>' +
-      '<input id="geoSearchInput" type="text" autocomplete="off" spellcheck="false" ' +
-      'placeholder="Search a county, city, or state…" aria-label="Location search">' +
-      '<button id="geoSearchClear" type="button" aria-label="Clear" style="display:none">✕</button></div>' +
-      '<div id="geoSearchResults"></div>';
-    document.body.appendChild(wrap);
-    const input = wrap.querySelector('#geoSearchInput');
-    const results = wrap.querySelector('#geoSearchResults');
-    const clear = wrap.querySelector('#geoSearchClear');
+  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+  const badge = ty => ({ county: 'County', metro: 'Metro', state: 'State', zip: 'ZIP', address: 'Address' }[ty] || ty);
+
+  function build(host, mode) {
+    host.className = 'geo-host geo-' + mode;
+    host.innerHTML =
+      '<div class="geo-box"><span class="geo-ic">⌕</span>' +
+      '<input class="geo-input" type="text" autocomplete="off" spellcheck="false" ' +
+      'placeholder="Search address, ZIP, city, or county…" aria-label="Location search">' +
+      '<button class="geo-clear" type="button" aria-label="Clear" style="display:none">✕</button></div>' +
+      '<div class="geo-results"></div>';
+    const input = host.querySelector('.geo-input');
+    const results = host.querySelector('.geo-results');
+    const clear = host.querySelector('.geo-clear');
     let items = [], active = -1, t = null, lastQ = '';
 
     const close = () => { results.innerHTML = ''; results.classList.remove('open'); items = []; active = -1; };
     const go = u => { if (u) location.href = u; };
-    const badge = ty => ty === 'county' ? 'County' : ty === 'metro' ? 'Metro' : 'State';
-    const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
 
     function render(rows) {
       items = rows;
       if (!rows.length) {
         const zipish = /^\d{5}$/.test(lastQ), addrish = /^\d+\s+\S/.test(lastQ);
         results.innerHTML = '<div class="geo-empty">' +
-          (zipish || addrish
-            ? 'No place match. ZIP / street-address lookup is per-county — open a market, then use its property search.'
+          (zipish ? `No county found for ZIP ${esc(lastQ)}.`
+            : addrish ? 'Address not recognized — try including city + state.'
             : 'No matches.') + '</div>';
         results.classList.add('open'); return;
       }
       results.innerHTML = rows.map((r, i) =>
         `<a class="geo-item" data-i="${i}" href="${esc(r.url)}">` +
-        `<span class="geo-name">${esc(r.name)}</span>` +
+        `<span class="geo-name">${esc(r.name)}${r.sub ? `<span class="geo-subline">${esc(r.sub)}</span>` : ''}</span>` +
         (r.state ? `<span class="geo-state">${esc(r.state)}</span>` : '') +
         `<span class="geo-badge">${badge(r.type)}</span></a>`).join('');
       results.classList.add('open'); active = -1;
@@ -56,7 +58,7 @@
       clear.style.display = q ? 'block' : 'none';
       clearTimeout(t);
       if (q.length < 2) { close(); return; }
-      t = setTimeout(() => search(q), 160);
+      t = setTimeout(() => search(q), 180);
     });
     input.addEventListener('keydown', e => {
       if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active + 1); }
@@ -65,7 +67,21 @@
       else if (e.key === 'Escape') { close(); input.blur(); }
     });
     clear.addEventListener('click', () => { input.value = ''; clear.style.display = 'none'; close(); input.focus(); });
-    document.addEventListener('click', e => { if (!wrap.contains(e.target)) close(); });
+    document.addEventListener('click', e => { if (!host.contains(e.target)) close(); });
+  }
+
+  function init() {
+    if (document.querySelector('.geo-host')) return;
+    const mountPill = () => { const h = document.createElement('div'); document.body.appendChild(h); build(h, 'pill'); };
+    if (document.querySelector('.cg-shell')) {
+      // grid page: wait for CrcpGrid to build .cg-top, then mount inline as its first control
+      let tries = 0;
+      const iv = setInterval(() => {
+        const top = document.querySelector('.cg-top');
+        if (top) { clearInterval(iv); const h = document.createElement('div'); top.insertBefore(h, top.firstChild); build(h, 'inline'); }
+        else if (++tries > 80) { clearInterval(iv); mountPill(); } // fallback if grid never mounts
+      }, 100);
+    } else mountPill();
   }
   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
 })();
diff --git a/public/listings.html b/public/listings.html
index 8ac5959..42627f5 100644
--- a/public/listings.html
+++ b/public/listings.html
@@ -40,6 +40,7 @@
   .src-tag{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--muted);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -188,5 +189,6 @@ loadFilters();
 load();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/market.html b/public/market.html
index 5657600..2d78873 100644
--- a/public/market.html
+++ b/public/market.html
@@ -42,6 +42,7 @@
   .u-legend .u-label{color:var(--muted);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -219,5 +220,6 @@ function chart(elId, entries, fmt){
 })();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/markets.html b/public/markets.html
index b61ab53..29329f1 100644
--- a/public/markets.html
+++ b/public/markets.html
@@ -29,6 +29,7 @@
   .score{font-weight:700;color:var(--gold);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -162,5 +163,6 @@ document.getElementById('exportcsv').addEventListener('click', e => {
 load(TYPE);
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/property.html b/public/property.html
index 936e0a4..c3ef4f3 100644
--- a/public/property.html
+++ b/public/property.html
@@ -62,6 +62,7 @@
   #foot{margin-top:22px;color:var(--muted);font-size:11px;}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -354,5 +355,6 @@ const loc = qs.get('loc');
 if (loc) load(loc);
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/sources.html b/public/sources.html
index 3cf067d..3f6b0a9 100644
--- a/public/sources.html
+++ b/public/sources.html
@@ -32,6 +32,7 @@
   .foot{color:var(--muted);font-size:12px;margin-top:26px;font-style:italic;}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -129,5 +130,6 @@ const int = n => (+n).toLocaleString();
 })();
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/public/watchlist.html b/public/watchlist.html
index 41a16d4..8be9c95 100644
--- a/public/watchlist.html
+++ b/public/watchlist.html
@@ -38,6 +38,7 @@
   .empty{color:var(--muted);padding:18px;text-align:center;}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
+<link rel="stylesheet" href="/geo-search.css">
 </head>
 <body>
 <div id="topbar">
@@ -161,5 +162,6 @@ async function loadAlerts(){
 loadRegions().then(()=>{ loadList(); loadAlerts(); });
 </script>
 <script src="/nav-drawer.js"></script>
+<script src="/geo-search.js"></script>
 </body>
 </html>
diff --git a/src/ingest/zip_county.ts b/src/ingest/zip_county.ts
new file mode 100644
index 0000000..1b4cd85
--- /dev/null
+++ b/src/ingest/zip_county.ts
@@ -0,0 +1,50 @@
+/**
+ * zip_county — national ZIP (ZCTA) -> primary county FIPS crosswalk that powers ZIP
+ * lookup in the top location search (/api/geo-search). Source: Census 2020 ZCTA-to-county
+ * relationship file (pipe-delimited, keyless, free). A ZIP can span counties; we keep the
+ * county with the largest land overlap (AREALAND_PART). Idempotent — rebuilds each run.
+ * Run: `npm run ingest:zipcounty` (local) or on prod after deploy.
+ */
+import { query, pool } from '../../db/pool.ts';
+
+const SRC = 'https://www2.census.gov/geo/docs/maps-data/data/rel2020/zcta520/tab20_zcta520_county20_natl.txt';
+
+async function main() {
+  console.log('[zip_county] downloading Census ZCTA→county relationship file…');
+  const res = await fetch(SRC);
+  if (!res.ok) throw new Error('download failed: HTTP ' + res.status);
+  const text = await res.text();
+  const lines = text.split('\n');
+  // fields (0-based): 1=GEOID_ZCTA5 (zip), 9=GEOID_COUNTY (fips), 16=AREALAND_PART
+  const best = new Map<string, { fips: string; area: number }>();
+  for (let i = 1; i < lines.length; i++) {
+    const f = lines[i].split('|');
+    if (f.length < 17) continue;
+    const zip = (f[1] || '').trim();
+    const fips = (f[9] || '').trim();
+    const area = Number(f[16] || 0);
+    if (!/^\d{5}$/.test(zip) || !/^\d{5}$/.test(fips)) continue;
+    const cur = best.get(zip);
+    if (!cur || area > cur.area) best.set(zip, { fips, area });
+  }
+  console.log(`[zip_county] parsed ${best.size} unique ZIPs`);
+  await query(`CREATE TABLE IF NOT EXISTS zip_county (zip text PRIMARY KEY, county_fips text NOT NULL)`);
+  await query(`TRUNCATE zip_county`);
+  const entries = [...best.entries()];
+  const CHUNK = 1000;
+  for (let i = 0; i < entries.length; i += CHUNK) {
+    const slice = entries.slice(i, i + CHUNK);
+    const vals: string[] = [];
+    const params: string[] = [];
+    slice.forEach(([zip, v], j) => { vals.push(`($${j * 2 + 1},$${j * 2 + 2})`); params.push(zip, v.fips); });
+    await query(
+      `INSERT INTO zip_county (zip, county_fips) VALUES ${vals.join(',')}
+         ON CONFLICT (zip) DO UPDATE SET county_fips = EXCLUDED.county_fips`,
+      params,
+    );
+  }
+  const c = await query<{ n: string }>(`SELECT count(*)::text AS n FROM zip_county`);
+  console.log(`[zip_county] loaded ${c.rows[0].n} rows`);
+  await pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index 64a9f49..c58f0bc 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -129,6 +129,38 @@ app.get('/api/geo-search', async (req, res) => {
   try {
     const q = String(req.query.q || '').trim();
     if (q.length < 2) return res.json({ q, results: [] });
+
+    // ZIP → primary county (zip_county crosswalk). National.
+    if (/^\d{5}$/.test(q)) {
+      const r = await query<{ canonical_key: string; name: string; state_code: string | null }>(
+        `SELECT rg.canonical_key, rg.name, rg.state_code
+           FROM zip_county zc JOIN region rg ON rg.fips = zc.county_fips AND rg.region_type = 'county'
+          WHERE zc.zip = $1`, [q]);
+      if (r.rows.length) { const x = r.rows[0];
+        return res.json({ q, results: [{ type: 'zip', name: `${x.name} — ZIP ${q}`, state: x.state_code || null,
+          key: x.canonical_key, url: '/market.html?key=' + encodeURIComponent(x.canonical_key) }] }); }
+      return res.json({ q, results: [] });
+    }
+
+    // street address (starts with a house number) → Census geocoder → county. National.
+    if (/^\d+\s+\S/.test(q)) {
+      try {
+        const gu = 'https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress?address='
+          + encodeURIComponent(q) + '&benchmark=Public_AR_Current&vintage=Current_Current&format=json';
+        const gj: any = await (await fetch(gu, { signal: AbortSignal.timeout(6000) })).json();
+        const m = gj?.result?.addressMatches?.[0];
+        const co = m?.geographies?.Counties?.[0];
+        if (co?.GEOID) {
+          const r = await query<{ canonical_key: string; name: string; state_code: string | null }>(
+            `SELECT canonical_key, name, state_code FROM region WHERE fips = $1 AND region_type = 'county'`, [co.GEOID]);
+          if (r.rows.length) { const x = r.rows[0];
+            return res.json({ q, results: [{ type: 'address', name: m.matchedAddress || q, state: x.state_code || null,
+              sub: x.name, key: x.canonical_key, url: '/market.html?key=' + encodeURIComponent(x.canonical_key) }] }); }
+        }
+      } catch (e) { /* geocoder timeout/failure → fall through to name search */ }
+      return res.json({ q, results: [] });
+    }
+
     const esc = q.replace(/[%_\\]/g, '\\$&');
     const like = '%' + esc + '%';
     const starts = esc + '%';

← 0a3f34d USRE: top location search (county/city/state) — /api/geo-sea  ·  back to Nationalrealestate  ·  USRE: searched address in a parcel-covered county deep-links 7c0710f →