[object Object]

← back to Nationalrealestate

auto-save: 2026-07-21T16:08:25 (5 files) — public/market.html public/property.html src/server/property.ts db/migrations/004_parcels.sql src/ingest/parcels/

a43523756856871b1f468ab99fcc0d6aaf49e90d · 2026-07-21 16:08:26 -0700 · Steve Abrams

Files touched

Diff

commit a43523756856871b1f468ab99fcc0d6aaf49e90d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 16:08:26 2026 -0700

    auto-save: 2026-07-21T16:08:25 (5 files) — public/market.html public/property.html src/server/property.ts db/migrations/004_parcels.sql src/ingest/parcels/
---
 db/migrations/004_parcels.sql   |  30 ++++++++
 public/market.html              |  31 +++++---
 public/property.html            |  43 +++++++++--
 src/ingest/parcels/nyc_pluto.ts | 124 ++++++++++++++++++++++++++++++
 src/server/property.ts          | 166 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 377 insertions(+), 17 deletions(-)

diff --git a/db/migrations/004_parcels.sql b/db/migrations/004_parcels.sql
new file mode 100644
index 0000000..0369872
--- /dev/null
+++ b/db/migrations/004_parcels.sql
@@ -0,0 +1,30 @@
+-- Generic parcel table for counties WITHOUT a pre-built SQLite (source_kind='pg').
+-- LA stays in its CRCP SQLite (source_kind='sqlite'); NYC/others land here.
+CREATE TABLE parcel (
+  county_fips TEXT NOT NULL,
+  source_id TEXT NOT NULL,          -- BBL (NYC), PIN, APN...
+  address TEXT,
+  norm_address TEXT,                -- upper/trimmed for prefix search
+  city TEXT,
+  zip TEXT,
+  lat DOUBLE PRECISION,
+  lng DOUBLE PRECISION,
+  year_built INT,
+  sqft NUMERIC,
+  beds NUMERIC,
+  baths NUMERIC,
+  units NUMERIC,
+  use_desc TEXT,
+  land_value NUMERIC,
+  improvement_value NUMERIC,
+  total_value NUMERIC,
+  tax_year TEXT,
+  owner_name TEXT,
+  zoning TEXT,
+  last_sale_date DATE,
+  last_sale_price NUMERIC,
+  extra JSONB,
+  PRIMARY KEY (county_fips, source_id)
+);
+CREATE INDEX idx_parcel_addr ON parcel (county_fips, norm_address text_pattern_ops);
+CREATE INDEX idx_parcel_use ON parcel (county_fips, use_desc);
diff --git a/public/market.html b/public/market.html
index 3fd3beb..25c8622 100644
--- a/public/market.html
+++ b/public/market.html
@@ -195,18 +195,25 @@ function chart(elId, entries, fmt){
   }
 
   // M-P1: counties with parcel coverage get a property-level search entry point
-  if (region.region_type === 'county' && region.fips === '06037') {
-    document.getElementById('parcels').innerHTML =
-      `<form style="display:flex;gap:8px;justify-content:center;align-items:center;flex-wrap:wrap;"
-             onsubmit="event.preventDefault();location.href='/property.html?county=06037&loc='+encodeURIComponent(this.q.value);">
-         <span style="color:var(--fg);">2.43M LA County parcels loaded —</span>
-         <input name="q" placeholder="search an address, e.g. 9321 Ventura Way" autocomplete="off"
-                style="background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--fg);
-                       padding:7px 12px;font-size:13px;min-width:260px;outline:none;">
-         <button type="submit" style="background:var(--gold);color:#0a0d13;border:0;border-radius:8px;
-                       padding:7px 14px;font-weight:700;cursor:pointer;">Open property</button>
-         <a href="/property.html?county=06037" style="color:var(--gold);font-size:12px;">property detail →</a>
-       </form>`;
+  if (region.region_type === 'county') {
+    try {
+      const cov = await fetch('/api/property/coverage').then(r => r.json());
+      const hit = (cov.counties || []).find(c => c.county_fips === region.fips);
+      if (hit) {
+        const cf = region.fips, ph = cf === '06037' ? '9321 Ventura Way' : '1 5 Avenue';
+        document.getElementById('parcels').innerHTML =
+          `<form style="display:flex;gap:8px;justify-content:center;align-items:center;flex-wrap:wrap;"
+                 onsubmit="event.preventDefault();location.href='/property.html?county=${cf}&loc='+encodeURIComponent(this.q.value);">
+             <span style="color:var(--fg);">${(+hit.parcel_count).toLocaleString()} parcels loaded —</span>
+             <input name="q" placeholder="search an address, e.g. ${ph}" autocomplete="off"
+                    style="background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--fg);
+                           padding:7px 12px;font-size:13px;min-width:260px;outline:none;">
+             <button type="submit" style="background:var(--gold);color:#0a0d13;border:0;border-radius:8px;
+                           padding:7px 14px;font-weight:700;cursor:pointer;">Open property</button>
+             <a href="/property.html?county=${cf}" style="color:var(--gold);font-size:12px;">property detail →</a>
+           </form>`;
+      }
+    } catch {}
   }
 })();
 </script>
diff --git a/public/property.html b/public/property.html
index 7bc8667..eccdee5 100644
--- a/public/property.html
+++ b/public/property.html
@@ -3,6 +3,7 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='10' text-anchor='middle' fill='%23c8a24b'%3EUS%3C/text%3E%3C/svg%3E">
 <title>USRealEstate — Property Detail</title>
 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
       integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
@@ -73,8 +74,9 @@
   </nav>
 </div>
 <main>
-  <form id="search">
-    <input id="q" type="text" placeholder="Search an LA County address — e.g. 9321 Ventura Way" autocomplete="off">
+  <form id="search" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
+    <select id="countysel" style="background:var(--bg,#0a0d13);border:1px solid var(--line,#222936);color:var(--fg,#e8ecf2);border-radius:8px;padding:8px 10px;font-size:13px;"></select>
+    <input id="q" type="text" placeholder="Search an address" autocomplete="off" style="flex:1;min-width:240px;">
     <button type="submit">Search</button>
   </form>
   <div id="results"></div>
@@ -108,7 +110,13 @@
         integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
 <script>
 const qs = new URLSearchParams(location.search);
-const county = qs.get('county') || '06037';
+let county = qs.get('county') || '06037';
+const COUNTY_LABELS = {
+  '06037': 'Los Angeles County, CA', '36061': 'Manhattan (New York County), NY',
+  '36047': 'Brooklyn (Kings County), NY', '36081': 'Queens County, NY',
+  '36005': 'Bronx County, NY', '36085': 'Staten Island (Richmond County), NY',
+};
+const PLACEHOLDER = { '06037': 'e.g. 9321 Ventura Way', dflt: 'e.g. 1 5 Avenue' };
 
 const usd0 = v => v == null ? '—' : '$' + Math.round(v).toLocaleString();
 const int0 = v => v == null ? '—' : Math.round(v).toLocaleString();
@@ -289,10 +297,35 @@ document.getElementById('search').addEventListener('submit', e => {
 
 // full 13-section skeleton always visible — never blank/hidden
 document.querySelector('#hdr h1').textContent = 'Search an address to load a property';
-document.getElementById('sub').textContent =
-  'Parcel coverage: Los Angeles County (06037) — 2.43M parcels from the 2025 secured roll. Other counties: coverage pending.';
 document.querySelectorAll('#prop .panel .body').forEach(b => { b.innerHTML = PENDING; });
 
+function applyCounty(){
+  document.getElementById('q').placeholder = 'Search an address — ' + (PLACEHOLDER[county] || PLACEHOLDER.dflt);
+  const lbl = COUNTY_LABELS[county] || county;
+  document.getElementById('sub').textContent =
+    'Parcel coverage: ' + lbl + '. Switch counties above; more counties added over time.';
+}
+
+async function initCounties(){
+  const sel = document.getElementById('countysel');
+  try {
+    const d = await fetch('/api/property/coverage').then(r => r.json());
+    const opts = (d.counties || []).map(c =>
+      `<option value="${c.county_fips}"${c.county_fips === county ? ' selected' : ''}>${COUNTY_LABELS[c.county_fips] || c.county_fips} · ${(+c.parcel_count).toLocaleString()} parcels</option>`).join('');
+    sel.innerHTML = opts || `<option value="06037">Los Angeles County, CA</option>`;
+  } catch { sel.innerHTML = `<option value="06037">Los Angeles County, CA</option>`; }
+  sel.onchange = () => {
+    county = sel.value;
+    document.getElementById('results').innerHTML = '';
+    document.querySelectorAll('#prop .panel .body').forEach(b => { b.innerHTML = PENDING; });
+    document.querySelector('#hdr h1').textContent = 'Search an address to load a property';
+    applyCounty();
+    history.replaceState(null, '', `?county=${county}`);
+  };
+  applyCounty();
+}
+
+initCounties();
 const loc = qs.get('loc');
 if (loc) load(loc);
 </script>
diff --git a/src/ingest/parcels/nyc_pluto.ts b/src/ingest/parcels/nyc_pluto.ts
new file mode 100644
index 0000000..c00ca2e
--- /dev/null
+++ b/src/ingest/parcels/nyc_pluto.ts
@@ -0,0 +1,124 @@
+/**
+ * NYC MapPLUTO parcel ingest (NYC Open Data, Socrata dataset 64uk-42ks) — ~860k lots.
+ * PLUTO is LOT-level (no beds/baths) but carries owner name, zoning, assessed values,
+ * lat/lng, year built — filling the Ownership + Zoning sections LA can't. The five
+ * boroughs map to five county FIPS; every lot is stored under its borough's county.
+ * $0 open data. Paginated JSON pull ($limit/$offset), batch upsert into `parcel`.
+ *
+ * Run: npx tsx src/ingest/parcels/nyc_pluto.ts   (or via engine.ts)
+ */
+import { query, pool } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+
+const DATASET = 'https://data.cityofnewyork.us/resource/64uk-42ks.json';
+const PAGE = 50000;
+
+// borocode -> [county_fips, borough label]
+const BORO: Record<string, [string, string]> = {
+  '1': ['36061', 'Manhattan'], '2': ['36005', 'Bronx'], '3': ['36047', 'Brooklyn'],
+  '4': ['36081', 'Queens'], '5': ['36085', 'Staten Island'],
+};
+const LANDUSE: Record<string, string> = {
+  '01': 'One & Two Family Buildings', '02': 'Multi-Family Walk-Up', '03': 'Multi-Family Elevator',
+  '04': 'Mixed Residential & Commercial', '05': 'Commercial & Office', '06': 'Industrial & Manufacturing',
+  '07': 'Transportation & Utility', '08': 'Public Facilities & Institutions', '09': 'Open Space & Outdoor',
+  '10': 'Parking Facilities', '11': 'Vacant Land',
+};
+
+const num = (v: unknown) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
+
+async function upsertBatch(all: any[]): Promise<number> {
+  if (!all.length) return 0;
+  // Postgres caps a statement at 65535 bind params; 19 cols -> keep chunks <= ~3000 rows.
+  let done = 0;
+  for (let i = 0; i < all.length; i += 3000) done += await upsertChunk(all.slice(i, i + 3000));
+  return done;
+}
+
+async function upsertChunk(rows: any[]): Promise<number> {
+  if (!rows.length) return 0;
+  const cols = ['county_fips', 'source_id', 'address', 'norm_address', 'city', 'zip', 'lat', 'lng',
+    'year_built', 'sqft', 'units', 'use_desc', 'land_value', 'improvement_value', 'total_value',
+    'tax_year', 'owner_name', 'zoning', 'extra'];
+  const params: unknown[] = [];
+  const values = rows.map((r, j) => {
+    const b = j * cols.length;
+    params.push(r.county_fips, r.source_id, r.address, r.norm_address, r.city, r.zip, r.lat, r.lng,
+      r.year_built, r.sqft, r.units, r.use_desc, r.land_value, r.improvement_value, r.total_value,
+      r.tax_year, r.owner_name, r.zoning, r.extra);
+    return '(' + cols.map((_, k) => `$${b + k + 1}`).join(',') + ')';
+  });
+  await query(
+    `INSERT INTO parcel (${cols.join(',')}) VALUES ${values.join(',')}
+     ON CONFLICT (county_fips, source_id) DO UPDATE SET
+       address=EXCLUDED.address, norm_address=EXCLUDED.norm_address, city=EXCLUDED.city, zip=EXCLUDED.zip,
+       lat=EXCLUDED.lat, lng=EXCLUDED.lng, year_built=EXCLUDED.year_built, sqft=EXCLUDED.sqft,
+       units=EXCLUDED.units, use_desc=EXCLUDED.use_desc, land_value=EXCLUDED.land_value,
+       improvement_value=EXCLUDED.improvement_value, total_value=EXCLUDED.total_value,
+       tax_year=EXCLUDED.tax_year, owner_name=EXCLUDED.owner_name, zoning=EXCLUDED.zoning, extra=EXCLUDED.extra`,
+    params,
+  );
+  return rows.length;
+}
+
+export async function ingestNycPluto(): Promise<{ upserted: number }> {
+  const runId = await openRun('nyc_pluto', DATASET);
+  let upserted = 0, offset = 0, page = 0;
+  try {
+    for (;;) {
+      const url = `${DATASET}?$limit=${PAGE}&$offset=${offset}&$order=bbl`;
+      const res = await fetch(url, { headers: { 'accept': 'application/json' } });
+      if (!res.ok) throw new Error(`PLUTO fetch ${res.status} at offset ${offset}: ${url}`);
+      const data = await res.json() as any[];
+      if (!data.length) break;
+      const batch = data.map(d => {
+        const boro = BORO[String(d.borocode)];
+        if (!boro || !d.bbl) return null;
+        const total = num(d.assesstot), land = num(d.assessland);
+        const addr = (d.address || '').trim();
+        return {
+          county_fips: boro[0], source_id: String(d.bbl),
+          address: addr || null, norm_address: addr ? addr.toUpperCase() : null,
+          city: boro[1], zip: d.zipcode || null,
+          lat: num(d.latitude), lng: num(d.longitude),
+          year_built: num(d.yearbuilt) || null,
+          sqft: num(d.bldgarea), units: num(d.unitstotal),
+          use_desc: LANDUSE[d.landuse] || (d.bldgclass ? `Class ${d.bldgclass}` : null),
+          land_value: land, improvement_value: total != null && land != null ? total - land : null,
+          total_value: total, tax_year: d.version ? String(d.version) : null,
+          owner_name: (d.ownername || '').trim() || null,
+          zoning: d.zonedist1 || null,
+          extra: JSON.stringify({ landuse: d.landuse, bldgclass: d.bldgclass, unitsres: d.unitsres,
+            lotarea: d.lotarea, numfloors: d.numfloors, borough: d.borough }),
+        };
+      }).filter(Boolean) as any[];
+      upserted += await upsertBatch(batch);
+      page++; offset += PAGE;
+      console.log(`[nyc_pluto] page ${page}: ${upserted} upserted`);
+      if (data.length < PAGE) break;
+    }
+    if (upserted < 500000) throw new Error(`only ${upserted} PLUTO rows — expected ~860k, format/endpoint drift?`);
+
+    // register all five boroughs in parcel_source
+    for (const [fips, label] of Object.values(BORO)) {
+      const cnt = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM parcel WHERE county_fips=$1`, [fips]);
+      await query(
+        `INSERT INTO parcel_source (county_fips, source_kind, path_or_url, parcel_count, notes, loaded_at)
+         VALUES ($1,'pg',$2,$3,$4,NOW())
+         ON CONFLICT (county_fips) DO UPDATE SET source_kind='pg', path_or_url=EXCLUDED.path_or_url,
+           parcel_count=EXCLUDED.parcel_count, notes=EXCLUDED.notes, loaded_at=NOW()`,
+        [fips, DATASET, Number(cnt.rows[0].n), `NYC MapPLUTO — ${label} (owner+zoning+assessed)`],
+      );
+    }
+    await closeRun(runId, 'ok', { upserted, notes: `NYC PLUTO five boroughs, ${page} pages` });
+    console.log(`[nyc_pluto] ok: ${upserted} lots across 5 boroughs`);
+    return { upserted };
+  } catch (e: any) {
+    await closeRun(runId, 'failed', { notes: String(e.message || e) });
+    throw e;
+  }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  ingestNycPluto().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}
diff --git a/src/server/property.ts b/src/server/property.ts
index 049e475..14a127f 100644
--- a/src/server/property.ts
+++ b/src/server/property.ts
@@ -84,6 +84,114 @@ const likeEscape = (s: string) => s.replace(/[%_]/g, '\\$&');
 
 const PENDING = 'coverage pending for this county';
 
+// ── shared county-level context (source-agnostic: keyed on county FIPS) ──
+async function countyContext(county: string) {
+  const rg = await query<any>(
+    `SELECT id, canonical_key, name, state_code, fips, cbsa_code, lat, lng, population
+       FROM region WHERE region_type = 'county' AND fips = $1`, [county],
+  );
+  const region = rg.rows[0] ?? null;
+  const latest: Record<string, number> = {};
+  let metroName: string | null = null;
+  let nri: { risk_rating: string | null; top_hazards: any } | null = null;
+  if (region) {
+    const mr = await query<{ metric: string; value: string }>(
+      `SELECT DISTINCT ON (metric) metric, value::text AS value
+         FROM metric_series WHERE region_id = $1 ORDER BY metric, period DESC`, [region.id],
+    );
+    for (const row of mr.rows) latest[row.metric] = Number(row.value);
+    if (region.cbsa_code) {
+      const met = await query<{ name: string }>(
+        `SELECT name FROM region WHERE region_type = 'metro' AND cbsa_code = $1`, [region.cbsa_code],
+      );
+      metroName = met.rows[0]?.name ?? null;
+    }
+    const nr = await query<{ risk_rating: string | null; top_hazards: any }>(
+      `SELECT risk_rating, top_hazards FROM nri_ratings WHERE county_fips = $1`, [county],
+    );
+    nri = nr.rows[0] ?? null;
+  }
+  return { region, latest, metroName, nri };
+}
+
+function buildContextSections(county: string, ctx: Awaited<ReturnType<typeof countyContext>>) {
+  const { region, latest, metroName, nri } = ctx;
+  const fmr = ['fmr_0br', 'fmr_1br', 'fmr_2br', 'fmr_3br', 'fmr_4br'].some(k => latest[k] != null)
+    ? { fmr_0br: latest.fmr_0br ?? null, fmr_1br: latest.fmr_1br ?? null, fmr_2br: latest.fmr_2br ?? null,
+        fmr_3br: latest.fmr_3br ?? null, fmr_4br: latest.fmr_4br ?? null } : null;
+  return {
+    climate: region ? {
+      risk_score: latest.nri_risk_score ?? null, eal_score: latest.nri_eal_score ?? null,
+      social_vulnerability: latest.nri_sovi_score ?? null, community_resilience: latest.nri_resl_score ?? null,
+      risk_rating: nri?.risk_rating ?? null, top_hazards: nri?.top_hazards ?? [],
+      source: 'FEMA National Risk Index v1.20.0 (Dec 2025), county level',
+    } : null,
+    rental: region ? {
+      fmr, fmr_year: fmr ? 'FY2026' : null, zori: latest.zori ?? null, rent_yield: latest.rent_yield ?? null,
+      median_gross_rent: latest.median_gross_rent ?? null,
+      source: 'HUD FMR FY2026 + Zillow ZORI + derived, county level',
+    } : null,
+    demographics: region ? {
+      population: latest.population ?? region.population ?? null, median_hh_income: latest.median_hh_income ?? null,
+      median_gross_rent: latest.median_gross_rent ?? null, vacancy_rate: latest.vacancy_rate ?? null,
+      renter_share: latest.renter_share ?? null, source: 'Census ACS 2023 5-yr, county level',
+    } : null,
+    location: region ? {
+      county_name: region.name, state: region.state_code, fips: region.fips,
+      metro: metroName, cbsa_code: region.cbsa_code, lat: region.lat, lng: region.lng,
+      zhvi: latest.zhvi ?? null, zhvi_yoy: latest.zhvi_yoy ?? null,
+      links: { market: '/market.html?key=' + encodeURIComponent(region.canonical_key) },
+    } : null,
+  };
+}
+
+// ── PG-backed parcels (NYC PLUTO etc: owner + zoning + assessed) ──
+interface PgParcel {
+  source_id: string; address: string; norm_address: string; city: string; zip: string;
+  lat: number | null; lng: number | null; year_built: number | null; sqft: number | null;
+  units: number | null; use_desc: string | null; land_value: number | null;
+  improvement_value: number | null; total_value: number | null; tax_year: string | null;
+  owner_name: string | null; zoning: string | null;
+}
+async function pgSearch(county: string, q: string) {
+  const norm = q.trim().toUpperCase();
+  const r = await query<PgParcel>(
+    `SELECT source_id, address, city, zip, year_built, sqft, units, use_desc, total_value, owner_name, zoning
+       FROM parcel WHERE county_fips = $1 AND norm_address LIKE $2 ESCAPE '\\'
+      ORDER BY norm_address LIMIT 25`, [county, likeEscape(norm) + '%'],
+  );
+  return r.rows.map((p: any) => ({
+    ain: p.source_id, address: p.address, year_built: p.year_built, sqft: p.sqft,
+    bedrooms: null, bathrooms: null, units: p.units, use: p.use_desc,
+    assessed_value: p.total_value, owner: p.owner_name, zoning: p.zoning,
+  }));
+}
+async function pgProperty(county: string, loc: string) {
+  let r = await query<PgParcel>(`SELECT * FROM parcel WHERE county_fips=$1 AND source_id=$2`, [county, loc]);
+  if (!r.rows.length) {
+    r = await query<PgParcel>(
+      `SELECT * FROM parcel WHERE county_fips=$1 AND norm_address LIKE $2 ESCAPE '\\' LIMIT 1`,
+      [county, likeEscape(loc.trim().toUpperCase()) + '%'],
+    );
+  }
+  const p = r.rows[0];
+  if (!p) return null;
+  // comps: same county, same use, sqft ±30%
+  let comps: any[] = [];
+  if (p.sqft && p.use_desc) {
+    const lo = Math.round(+p.sqft * 0.7), hi = Math.round(+p.sqft * 1.3);
+    const c = await query<any>(
+      `SELECT source_id, address, year_built, sqft, units, total_value, owner_name
+         FROM parcel WHERE county_fips=$1 AND use_desc=$2 AND source_id != $3
+           AND sqft BETWEEN $4 AND $5 ORDER BY total_value DESC NULLS LAST LIMIT 8`,
+      [county, p.use_desc, p.source_id, lo, hi],
+    );
+    comps = c.rows.map((x: any) => ({ ain: x.source_id, address: x.address, year_built: x.year_built,
+      sqft: x.sqft, units: x.units, assessed_value: x.total_value, owner: x.owner_name }));
+  }
+  return { p, comps };
+}
+
 export function mountProperty(app: Express) {
   // address search within a parcel-covered county (LA-only for now)
   app.get('/api/property/search', async (req: Request, res: Response) => {
@@ -91,6 +199,12 @@ export function mountProperty(app: Express) {
       const county = String(req.query.county || LA_FIPS);
       const q = String(req.query.q || '').trim();
       if (!q) return res.status(400).json({ error: 'q required' });
+      const src = await parcelSourceFor(county);
+      if (!src) return res.status(404).json({ error: `no parcel source loaded for county ${county}` });
+      if (src.source_kind === 'pg') {
+        const matches = await pgSearch(county, q);
+        return res.json({ county, q, count: matches.length, matches });
+      }
       const db = await getCountyDb(county);
       if (!db) return res.status(404).json({ error: `no parcel source loaded for county ${county}` });
 
@@ -129,6 +243,58 @@ export function mountProperty(app: Express) {
       const county = String(req.query.county || LA_FIPS);
       const loc = String(req.query.loc || '').trim();
       if (!loc) return res.status(400).json({ error: 'loc required (ain or property_location)' });
+      const src0 = await parcelSourceFor(county);
+      if (!src0) return res.status(404).json({ error: `no parcel source loaded for county ${county}` });
+
+      // ── PG-backed counties (NYC PLUTO: owner + zoning + assessed values present) ──
+      if (src0.source_kind === 'pg') {
+        const found = await pgProperty(county, loc);
+        if (!found) return res.status(404).json({ error: 'parcel not found: ' + loc });
+        const { p, comps } = found;
+        const ctx = await countyContext(county);
+        const total = p.total_value != null ? +p.total_value : null;
+        return res.json({
+          county, region_key: ctx.region?.canonical_key ?? null,
+          property_details: {
+            ain: p.source_id, address: p.address, use: p.use_desc, use_category: p.use_desc,
+            year_built: p.year_built || null, effective_year_built: null,
+            sqft: p.sqft || null, bedrooms: null, bathrooms: null, units: p.units,
+            note: 'NYC MapPLUTO is lot-level — building sqft shown; unit-level beds/baths not in PLUTO.',
+          },
+          property_history: {
+            last_recording_date: null, sale_price: null,
+            note: 'Sale/deed chain from ACRIS pending (M-P2 next); PLUTO carries current attributes only.',
+          },
+          tax_history: {
+            rolls: [{ roll_year: p.tax_year, total_value: total,
+              land_value: p.land_value != null ? +p.land_value : null,
+              improvement_value: p.improvement_value != null ? +p.improvement_value : null }],
+            note: 'NYC assessed values from MapPLUTO (' + (p.tax_year || 'current') + ' version).',
+          },
+          contact_information: { phone: null, email: null,
+            note: 'Owner mailing/contact via NYC ACRIS pending; owner name shown below.' },
+          ownership_information: {
+            owner_name: p.owner_name, mailing_address: null, owner_occupied: null,
+            note: p.owner_name ? 'Owner of record from NYC MapPLUTO.' : PENDING,
+          },
+          zoning: { code: p.zoning,
+            note: p.zoning ? 'Primary zoning district (zonedist1) from NYC MapPLUTO.' : PENDING },
+          contacts: {
+            county_assessor: { name: 'NYC Department of Finance', phone: '311',
+              website: 'https://www.nyc.gov/site/finance/property/property.page' },
+            brokers_link: '/brokers.html?state=NY',
+            note: 'Local brokers from the NY licensing registry; per-parcel listing agent pending.',
+          },
+          map: {
+            lat: p.lat ?? ctx.region?.lat ?? null, lng: p.lng ?? ctx.region?.lng ?? null,
+            precision: p.lat ? 'parcel' : 'county_centroid',
+            note: p.lat ? 'Parcel coordinates from MapPLUTO.' : 'County centroid (parcel geocode missing).',
+          },
+          comps,
+          ...buildContextSections(county, ctx),
+        });
+      }
+
       const db = await getCountyDb(county);
       if (!db) return res.status(404).json({ error: `no parcel source loaded for county ${county}` });
 

← 58c461c CSV export on rankings (/api/markets.csv, type-synced downlo  ·  back to Nationalrealestate  ·  M-P2: NYC MapPLUTO parcels (858,602 lots, 5 boroughs) — prop da797f6 →