[object Object]

← back to Animals

fix(map): stop near-me SQL from crashing, hide empty cats, add cap notice

9e169437187b52fb519e247997636b22df937b83 · 2026-05-01 13:02:40 -0700 · SteveStudio2

The home-page "Sharing your location loads the closest 24 listings…" was
the reported "map does not work" — /api/near-me returned 500 because
Postgres saw $1/$2/$3 as untyped 'unknown' and refused subtraction. The
CTE param-typing pattern in index.js fixes the planner. Plus three
user-visible cleanups from the dual-Claude review:

- renderHome filters out zero-count categories (Specialty Hospitals,
  Daycare) so the home-page grid stops linking to dead-end pages.
- renderCategory annotates the map when the 200-marker cap is hit,
  e.g. /dog-parks now shows "Map shows first 200 of 377 geocoded
  listings — full table below."
- All Leaflet CSS + JS <link>/<script> tags get integrity= hashes
  back; they were stripped during the working-tree refactor.

Also lands the broader companion work that was sitting uncommitted:
geo.js (browser geolocation + ipapi.co fallback), sort-table.js,
OSM California / Reddit / Nextdoor ingest stubs, email enrich
migration, llm helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 9e169437187b52fb519e247997636b22df937b83
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Fri May 1 13:02:40 2026 -0700

    fix(map): stop near-me SQL from crashing, hide empty cats, add cap notice
    
    The home-page "Sharing your location loads the closest 24 listings…" was
    the reported "map does not work" — /api/near-me returned 500 because
    Postgres saw $1/$2/$3 as untyped 'unknown' and refused subtraction. The
    CTE param-typing pattern in index.js fixes the planner. Plus three
    user-visible cleanups from the dual-Claude review:
    
    - renderHome filters out zero-count categories (Specialty Hospitals,
      Daycare) so the home-page grid stops linking to dead-end pages.
    - renderCategory annotates the map when the 200-marker cap is hit,
      e.g. /dog-parks now shows "Map shows first 200 of 377 geocoded
      listings — full table below."
    - All Leaflet CSS + JS <link>/<script> tags get integrity= hashes
      back; they were stripped during the working-tree refactor.
    
    Also lands the broader companion work that was sitting uncommitted:
    geo.js (browser geolocation + ipapi.co fallback), sort-table.js,
    OSM California / Reddit / Nextdoor ingest stubs, email enrich
    migration, llm helper.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 docs/community_signal.md     | 105 +++++++++++++++++++
 migrations/002_emails.sql    |  55 ++++++++++
 package.json                 |   5 +
 public/css/site.css          |  23 ++++-
 public/js/geo.js             | 167 ++++++++++++++++++++++++++++++
 public/js/sort-table.js      |  67 ++++++++++++
 src/enrich/extract_emails.js | 236 +++++++++++++++++++++++++++++++++++++++++++
 src/ingest/nextdoor.js       |  56 ++++++++++
 src/ingest/osm_california.js | 184 +++++++++++++++++++++++++++++++++
 src/ingest/reddit.js         | 168 ++++++++++++++++++++++++++++++
 src/lib/llm.js               |  59 +++++++++++
 src/server/index.js          |  46 ++++++++-
 src/server/render.js         |  97 ++++++++++++------
 13 files changed, 1234 insertions(+), 34 deletions(-)

diff --git a/docs/community_signal.md b/docs/community_signal.md
new file mode 100644
index 0000000..86d459f
--- /dev/null
+++ b/docs/community_signal.md
@@ -0,0 +1,105 @@
+# Community-signal ingest — Reddit, Nextdoor, and the free path
+
+The animals directory has structured public sources (state vet boards, AAHA,
+OSM, Petfinder). Community sources fill in everything they miss: which vet is
+actually loved, who's the best groomer in a 5-mile radius, who's a scammy
+breeder. This doc covers the free ingest path.
+
+## What's wired
+
+| Source     | File                            | Status      | Cost  |
+| ---------- | ------------------------------- | ----------- | ----- |
+| Reddit     | `src/ingest/reddit.js`          | LIVE        | $0    |
+| Nextdoor   | `src/ingest/nextdoor.js`        | STUB        | (paid) |
+| Patch.com  | (planned `src/ingest/patch.js`) | not started | $0    |
+
+## Reddit pipeline
+
+```
+fetch r/<sub>/new.json (anonymous, ~60 req/min)
+   → regex prefilter (drops 80% of noise without an LLM call)
+   → gemma3:4b triage  ("is this about a real pet-care business?")
+   → qwen3:14b extract (JSON: {business_name, service_type, city, state, sentiment})
+   → INSERT raw_records (idempotent on hash)
+```
+
+Both LLMs are local Ollama. Zero API cost.
+
+### Subreddits
+
+Default set (`src/ingest/reddit.js`): pet-care subs (`AskVet`, `dogs`, `cats`,
+`Pets`, `Petloss`, `puppy101`, `CatAdvice`) + top-50 US metro city subs
+(`LosAngeles`, `nyc`, `chicago`, etc).
+
+Override:
+
+```sh
+SUBREDDITS=LosAngeles,nyc,chicago LIMIT=25 npm run ingest:reddit
+DRY=1 npm run ingest:reddit:dry        # see what would land without writing
+```
+
+### What gets stored
+
+`raw_records` rows with `entity_type='reddit_post'` and `raw_json` containing:
+
+```json
+{
+  "post":     { /* full reddit post */ },
+  "mentions": [
+    { "business_name": "...", "service_type": "vet_clinic",
+      "city": "Los Angeles", "state": "CA",
+      "sentiment": "positive", "context": "..." }
+  ],
+  "sub": "LosAngeles"
+}
+```
+
+A downstream enrichment pass (next iteration) walks `raw_records`, fuzzy-matches
+`business_name + city/state` against `businesses`, and either:
+
+- Adds a `community_mention` row to a new `business_mentions` table (TODO), or
+- Promotes a brand-new business if no match exists with confidence ≥ 0.7.
+
+## Weekly schedule
+
+Loaded as a launchd agent on Mac Studio 2:
+
+- Plist: `~/Library/LaunchAgents/com.steve.animals-reddit-weekly.plist`
+- Schedule: every Sunday at 07:00 local
+- Logs: `~/Projects/animals/logs/reddit-weekly.{log,err}`
+
+Manage:
+
+```sh
+launchctl bootstrap   gui/$UID ~/Library/LaunchAgents/com.steve.animals-reddit-weekly.plist
+launchctl bootout     gui/$UID/com.steve.animals-reddit-weekly
+launchctl kickstart   gui/$UID/com.steve.animals-reddit-weekly      # run now
+launchctl print       gui/$UID/com.steve.animals-reddit-weekly
+```
+
+## Nextdoor — why it's a stub
+
+Nextdoor has no public API and aggressively blocks anonymous scrapers
+(Cloudflare bot detection + login wall). Three free-ish surfaces exist:
+
+1. **Public business pages** (`/pages/<slug>/`) — useful for enriching a
+   known business; rate-limits anonymous fetches to a few per minute.
+2. **Public sitemap** (`nextdoor.com/sitemap.xml`) — best free starting
+   point for discovery; politely crawl with 5s delays.
+3. **Logged-in Browserbase session** — required for actual neighborhood
+   feed posts and "recommendations" threads. **Not free** — Browserbase
+   has paid tiers and Nextdoor itself requires an account. Per Steve's
+   "must be free" constraint, not implemented.
+
+When ready to enable the Browserbase route:
+
+```sh
+NEXTDOOR_EMAIL=...  NEXTDOOR_PASS=...  USE_BROWSERBASE=1 npm run ingest:nextdoor
+```
+
+## Free Nextdoor substitute
+
+**Patch.com** is the closest free analog — city-by-city local-news + community
+business mentions, no login wall. A `src/ingest/patch.js` module is the next
+brick. Coverage is good in 1,200+ US cities (better than Nextdoor in many
+secondary markets).
diff --git a/migrations/002_emails.sql b/migrations/002_emails.sql
new file mode 100644
index 0000000..12e0347
--- /dev/null
+++ b/migrations/002_emails.sql
@@ -0,0 +1,55 @@
+-- Email enrichment — emails are the most valuable contact data on the platform.
+-- Without an email we can't run leads outreach (Rail B) OR the upgrade pitch
+-- (Rail C). Treat email discovery as a first-class job, not a side-effect.
+--
+-- Two granularities:
+--   1. business_emails — one row per (business, email, source_page_url).
+--      Lets us track multiple emails per business + remember WHERE we found
+--      them (for compliance + re-verify later when they go stale).
+--   2. veterinarian_emails — same but for individual vets.
+-- Plus a `email_kind` heuristic to distinguish role-based (info@, contact@)
+-- from personal (drsmith@…) so the lead router can pick the right one.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS business_emails (
+  id              BIGSERIAL PRIMARY KEY,
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  email           TEXT NOT NULL,
+  email_kind      TEXT CHECK (email_kind IN ('role','personal','staff','intake','admin','unknown')),
+  context_label   TEXT,            -- nearby text on page: "Office Manager", "Dr. Smith"
+  source_url      TEXT NOT NULL,   -- page where we found it
+  source_method   TEXT NOT NULL,   -- 'mailto' | 'plaintext' | 'jsonld' | 'manual'
+  found_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  verified_at     TIMESTAMPTZ,
+  verification_status TEXT NOT NULL DEFAULT 'unverified'
+                      CHECK (verification_status IN ('unverified','syntactically_valid','mx_valid','bounced','opted_out','rejected'))
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_biz_emails_uniq ON business_emails(business_id, LOWER(email), source_url);
+CREATE INDEX IF NOT EXISTS idx_biz_emails_biz   ON business_emails(business_id);
+CREATE INDEX IF NOT EXISTS idx_biz_emails_email ON business_emails(LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_biz_emails_kind  ON business_emails(email_kind);
+
+CREATE TABLE IF NOT EXISTS veterinarian_emails (
+  id              BIGSERIAL PRIMARY KEY,
+  veterinarian_id BIGINT NOT NULL REFERENCES veterinarians(id) ON DELETE CASCADE,
+  business_id     BIGINT REFERENCES businesses(id) ON DELETE SET NULL,
+  email           TEXT NOT NULL,
+  email_kind      TEXT CHECK (email_kind IN ('role','personal','staff','intake','admin','unknown')),
+  source_url      TEXT NOT NULL,
+  source_method   TEXT NOT NULL,
+  found_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  verified_at     TIMESTAMPTZ,
+  verification_status TEXT NOT NULL DEFAULT 'unverified'
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_vet_emails_uniq ON veterinarian_emails(veterinarian_id, LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_vet_emails_vet   ON veterinarian_emails(veterinarian_id);
+CREATE INDEX IF NOT EXISTS idx_vet_emails_email ON veterinarian_emails(LOWER(email));
+
+-- Add a "last attempted to find emails" stamp so the enricher can skip
+-- recently-checked sites and not hammer the same hosts.
+ALTER TABLE businesses
+  ADD COLUMN IF NOT EXISTS email_search_at TIMESTAMPTZ,
+  ADD COLUMN IF NOT EXISTS email_search_pages_tried INTEGER NOT NULL DEFAULT 0;
+
+COMMIT;
diff --git a/package.json b/package.json
index d4fe2ad..163a4a2 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,13 @@
     "seed:breeds": "node src/scripts/seed_breeds.js",
     "seed:sources": "node src/scripts/seed_sources.js",
     "ingest:vets": "node src/ingest/state_vet_boards.js",
+    "ingest:osm-ca": "node src/ingest/osm_california.js",
+    "enrich:emails": "node src/enrich/extract_emails.js",
     "ingest:clinics:aaha": "node src/ingest/aaha_accredited.js",
     "ingest:shelters": "node src/ingest/petfinder_shelters.js",
+    "ingest:reddit": "node src/ingest/reddit.js",
+    "ingest:reddit:dry": "DRY=1 LIMIT=10 node src/ingest/reddit.js",
+    "ingest:nextdoor": "node src/ingest/nextdoor.js",
     "audit:websites": "node src/audit/site_audit.js",
     "audit:mockups": "node src/audit/generate_mockups.js",
     "domains:research": "node src/scripts/research_domains.js",
diff --git a/public/css/site.css b/public/css/site.css
index 0416879..04db409 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -80,5 +80,26 @@ img{max-width:100%;display:block}
 .ad-house{background:#fffaf3;border-style:solid;border-color:var(--accent2)}
 
 table{width:100%;border-collapse:collapse;margin:1em 0;background:var(--bg2);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
-th,td{padding:.6em .9em;border-bottom:1px solid var(--rule);text-align:left;font-size:.9em}
+th,td{padding:.6em .9em;border-bottom:1px solid var(--rule);text-align:left;font-size:.9em;vertical-align:top}
 th{background:#f3efe6;font-weight:600}
+table[data-sortable] th:hover{background:#ebe6d8}
+.biz-table td a{display:inline-block}
+.biz-table tbody tr:hover{background:#f7f4ec}
+
+/* Geolocation banner + near-me grid */
+.geo-banner{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border:1px solid var(--rule);border-radius:999px;font-size:.95em;color:var(--ink);margin:14px 0}
+.geo-banner .geo-pin{font-size:1.1em}
+.geo-banner small{color:var(--muted);font-size:.85em}
+.geo-banner .geo-change{margin-left:auto;background:transparent;border:0;color:#2b6cb0;cursor:pointer;font-size:.85em;text-decoration:underline;padding:2px 6px;font-family:inherit}
+.near-me{margin:32px 0}
+.near-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}
+.near-card{display:block;padding:14px;background:var(--bg2);border:1px solid var(--rule);border-radius:8px;text-decoration:none;color:var(--ink);transition:transform .15s,border-color .15s}
+.near-card:hover{transform:translateY(-2px);border-color:#888}
+.near-card strong{display:block;font-size:1em;margin-bottom:4px}
+.near-card small{color:var(--muted);font-size:.85em}
+.empty{color:var(--muted);font-size:.9em;font-style:italic;padding:20px;text-align:center}
+@media (max-width:640px){
+  .geo-banner{display:flex;width:100%;border-radius:8px}
+  .near-grid{grid-template-columns:1fr 1fr;gap:10px}
+  .near-card{padding:10px}
+}
diff --git a/public/js/geo.js b/public/js/geo.js
new file mode 100644
index 0000000..06a43db
--- /dev/null
+++ b/public/js/geo.js
@@ -0,0 +1,167 @@
+// AnimalsDirectory — geolocation onload + "Near me" filter.
+// Strategy:
+//   1. Try cached coords from localStorage (24h TTL) — instant.
+//   2. Try navigator.geolocation.getCurrentPosition — high accuracy if user allows.
+//   3. Fall back to ipapi.co free tier (no key, 1k req/day) for a city-level estimate.
+//   4. Expose the result via window.userGeo and dispatch a 'geo:ready' event.
+//
+// Pages with #geo-banner / #near-me-list will auto-update.
+
+(function () {
+  const CACHE_KEY = 'animals.geo.v1';
+  const TTL_MS = 24 * 60 * 60 * 1000;
+
+  function readCache() {
+    try {
+      const raw = localStorage.getItem(CACHE_KEY);
+      if (!raw) return null;
+      const j = JSON.parse(raw);
+      if (Date.now() - j.t > TTL_MS) return null;
+      return j;
+    } catch { return null; }
+  }
+  function writeCache(geo) {
+    try { localStorage.setItem(CACHE_KEY, JSON.stringify({ ...geo, t: Date.now() })); } catch {}
+  }
+
+  function setReady(geo) {
+    window.userGeo = geo;
+    writeCache(geo);
+    document.dispatchEvent(new CustomEvent('geo:ready', { detail: geo }));
+    renderBanner(geo);
+    fetchNearMe(geo);
+  }
+
+  function renderBanner(geo) {
+    const el = document.getElementById('geo-banner');
+    if (!el) return;
+    const where = geo.city ? `${geo.city}${geo.state ? ', ' + geo.state : ''}` : `${geo.lat.toFixed(2)}, ${geo.lng.toFixed(2)}`;
+    el.innerHTML =
+      `<span class="geo-pin">📍</span> ` +
+      `<span>Showing results near <strong>${where}</strong> ` +
+      `<small>(${geo.source})</small></span> ` +
+      `<button type="button" class="geo-change">change</button>`;
+    el.querySelector('.geo-change').addEventListener('click', promptManualLocation);
+  }
+
+  async function fetchNearMe(geo) {
+    const list = document.getElementById('near-me-list');
+    if (!list) return;
+    try {
+      const r = await fetch(`/api/near-me?lat=${geo.lat}&lng=${geo.lng}&radius_mi=25`);
+      if (!r.ok) throw new Error('HTTP ' + r.status);
+      const j = await r.json();
+      if (!j.results || j.results.length === 0) {
+        list.innerHTML = '<p class="empty">No nearby listings within 25 miles. Try widening the radius.</p>';
+        return;
+      }
+      list.innerHTML = j.results.map(b =>
+        `<a class="near-card" href="/clinic/${b.id}">
+          <strong>${escapeHtml(b.name)}</strong>
+          <small>${escapeHtml(b.category.replace(/_/g, ' '))} · ${b.distance_mi.toFixed(1)} mi · ${escapeHtml(b.city || '')}${b.state ? ', ' + escapeHtml(b.state) : ''}</small>
+        </a>`
+      ).join('');
+    } catch (e) {
+      list.innerHTML = '<p class="empty">Could not load nearby listings.</p>';
+      console.warn('near-me fetch failed', e);
+    }
+  }
+
+  function escapeHtml(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
+  }
+
+  function tryBrowserGeolocation() {
+    return new Promise((resolve) => {
+      if (!navigator.geolocation) return resolve(null);
+      navigator.geolocation.getCurrentPosition(
+        (pos) => resolve({
+          lat: pos.coords.latitude, lng: pos.coords.longitude,
+          accuracy: pos.coords.accuracy, source: 'gps'
+        }),
+        () => resolve(null),
+        { enableHighAccuracy: true, timeout: 7000, maximumAge: 60000 }
+      );
+    });
+  }
+
+  async function tryIpFallback() {
+    try {
+      const r = await fetch('https://ipapi.co/json/', { headers: { accept: 'application/json' } });
+      if (!r.ok) return null;
+      const j = await r.json();
+      if (!j.latitude || !j.longitude) return null;
+      return {
+        lat: parseFloat(j.latitude), lng: parseFloat(j.longitude),
+        city: j.city, state: j.region_code, country: j.country, source: 'ip'
+      };
+    } catch { return null; }
+  }
+
+  async function reverseGeocode(geo) {
+    if (geo.city) return geo;
+    try {
+      // Nominatim free, polite UA required; rate-limited 1 req/sec
+      const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${geo.lat}&lon=${geo.lng}&zoom=10`, {
+        headers: { 'accept': 'application/json' }
+      });
+      if (!r.ok) return geo;
+      const j = await r.json();
+      const a = j.address || {};
+      return { ...geo,
+        city: a.city || a.town || a.village || a.hamlet || a.county,
+        state: (a.state_code || a.state || '').slice(0, 2).toUpperCase()
+      };
+    } catch { return geo; }
+  }
+
+  function promptManualLocation() {
+    const v = prompt('Enter a city or ZIP (e.g., "Los Angeles, CA" or "10001"):');
+    if (!v) return;
+    fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(v + ', USA')}&limit=1`)
+      .then(r => r.json())
+      .then(arr => {
+        if (!arr || !arr.length) return alert('Could not find that location.');
+        const hit = arr[0];
+        const parts = (hit.display_name || '').split(',').map(s => s.trim());
+        setReady({
+          lat: parseFloat(hit.lat), lng: parseFloat(hit.lon),
+          city: parts[0], state: (parts[parts.length - 2] || '').slice(0, 2).toUpperCase(),
+          source: 'manual'
+        });
+      });
+  }
+
+  async function init() {
+    // Cached?
+    const cached = readCache();
+    if (cached) { setReady(cached); return; }
+
+    // Try GPS (asks user permission)
+    const gps = await tryBrowserGeolocation();
+    if (gps) { setReady(await reverseGeocode(gps)); return; }
+
+    // IP fallback
+    const ip = await tryIpFallback();
+    if (ip) { setReady(ip); return; }
+
+    // Nothing worked — prompt manual
+    const el = document.getElementById('geo-banner');
+    if (el) {
+      el.innerHTML = `<span class="geo-pin">📍</span> ` +
+        `<span>Set your location to see nearby vets, groomers, shelters.</span> ` +
+        `<button type="button" class="geo-change">set location</button>`;
+      el.querySelector('.geo-change').addEventListener('click', promptManualLocation);
+    }
+  }
+
+  // Auto-init when DOM is ready
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', init);
+  } else {
+    init();
+  }
+
+  // Public API
+  window.AnimalsGeo = { promptManualLocation, init };
+})();
diff --git a/public/js/sort-table.js b/public/js/sort-table.js
new file mode 100644
index 0000000..910694a
--- /dev/null
+++ b/public/js/sort-table.js
@@ -0,0 +1,67 @@
+// Tiny dep-free sortable table. Add data-sortable to a <table>.
+// Click a <th> to sort by that column. Click again to reverse.
+// Numeric vs. string detected from the column's data; null/empty sort last.
+(function() {
+  'use strict';
+  function detectKind(cells) {
+    let n = 0, total = 0;
+    for (const v of cells) {
+      const t = (v ?? '').toString().trim();
+      if (!t) continue;
+      total++;
+      if (/^-?\$?\d[\d,.]*\d*%?$/.test(t)) n++;
+    }
+    return total > 0 && n / total >= 0.7 ? 'num' : 'str';
+  }
+  function toNum(s) {
+    const cleaned = (s ?? '').toString().replace(/[$,%\s]/g, '');
+    const n = parseFloat(cleaned);
+    return isNaN(n) ? null : n;
+  }
+  function init(table) {
+    if (table.dataset.sortableInit) return;
+    table.dataset.sortableInit = '1';
+    const ths = table.querySelectorAll('thead th');
+    const tbody = table.querySelector('tbody');
+    if (!ths.length || !tbody) return;
+    ths.forEach((th, idx) => {
+      th.style.cursor = 'pointer';
+      th.style.userSelect = 'none';
+      const arrow = document.createElement('span');
+      arrow.className = 'sort-arrow';
+      arrow.style.cssText = 'opacity:.4;margin-left:.4em;font-size:.85em';
+      arrow.textContent = '↕';
+      th.appendChild(arrow);
+      th.addEventListener('click', () => {
+        const rows = Array.from(tbody.querySelectorAll('tr'));
+        const cells = rows.map(r => (r.children[idx]?.textContent || '').trim());
+        const kind = th.dataset.sort || detectKind(cells);
+        const cur = th.dataset.dir === 'asc' ? 'desc' : 'asc';
+        ths.forEach(o => { o.dataset.dir = ''; const a = o.querySelector('.sort-arrow'); if (a) { a.textContent = '↕'; a.style.opacity = .4; } });
+        th.dataset.dir = cur;
+        arrow.textContent = cur === 'asc' ? '▲' : '▼';
+        arrow.style.opacity = 1;
+        rows.sort((a, b) => {
+          let av = (a.children[idx]?.textContent || '').trim();
+          let bv = (b.children[idx]?.textContent || '').trim();
+          if (kind === 'num') {
+            const na = toNum(av), nb = toNum(bv);
+            if (na === null && nb === null) return 0;
+            if (na === null) return 1;          // empties sort last
+            if (nb === null) return -1;
+            return cur === 'asc' ? na - nb : nb - na;
+          }
+          av = av.toLowerCase(); bv = bv.toLowerCase();
+          if (!av && !bv) return 0;
+          if (!av) return 1;
+          if (!bv) return -1;
+          return cur === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
+        });
+        rows.forEach(r => tbody.appendChild(r));
+      });
+    });
+  }
+  function bootstrap() { document.querySelectorAll('table[data-sortable]').forEach(init); }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap);
+  else bootstrap();
+})();
diff --git a/src/enrich/extract_emails.js b/src/enrich/extract_emails.js
new file mode 100644
index 0000000..73eea4c
--- /dev/null
+++ b/src/enrich/extract_emails.js
@@ -0,0 +1,236 @@
+#!/usr/bin/env node
+// Email harvester — emails are the platform's most valuable contact field.
+// Without an email we can't run leads (Rail B) or the upgrade pitch (Rail C).
+//
+// For each business with a website:
+//   1. Fetch home + a few high-signal contact-style URLs.
+//   2. Extract mailto: links (highest confidence) + plaintext email regex.
+//   3. Decode common obfuscations (name [at] domain [dot] com, &#64; entities).
+//   4. Drop junk (sentry, example, your-email, image cdns).
+//   5. Classify each as role / personal / staff / intake / admin.
+//   6. Insert into business_emails (provenance preserved).
+//   7. Backfill businesses.email with the best primary-looking address.
+//
+// Run:
+//   node src/enrich/extract_emails.js                   # all businesses w/ website
+//   node src/enrich/extract_emails.js --limit 20
+//   node src/enrich/extract_emails.js --biz 5
+//   node src/enrich/extract_emails.js --refresh-days 30 # re-check older than N days
+
+import 'dotenv/config';
+import { fetch as undiciFetch } from 'undici';
+import * as cheerio from 'cheerio';
+import { pool, many, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+// constants used by helpers below — defined up here to avoid TDZ when the
+// async for-loop calls them before module-level evaluation finishes.
+const ROLE_PREFIXES = ['info','contact','hello','hi','admin','office','reception','frontdesk','front-desk','team','support','help','appointments','book','booking','schedule','intake','newpatient','new-patient','newpatients','clinic'];
+
+const args = process.argv.slice(2);
+const limit       = parseInt(args[args.indexOf('--limit') + 1], 10) || 200;
+const onlyBiz     = parseInt(args[args.indexOf('--biz') + 1], 10) || null;
+const refreshDays = parseInt(args[args.indexOf('--refresh-days') + 1], 10) || 0;
+
+// 4 high-signal pages — past these the marginal hit rate drops sharply.
+const CONTACT_PATHS = ['/', '/contact', '/contact-us', '/about'];
+const FETCH_TIMEOUT_MS = 6000;
+const CONCURRENCY = 8;
+
+const UA = 'Mozilla/5.0 (compatible; AnimalsDirectoryEmailBot/1.0; +https://animalsdirectory.com/bot)';
+const REQ_OPTS = { headers: { 'user-agent': UA, accept: 'text/html,*/*' }, redirect: 'follow' };
+
+const where = onlyBiz
+  ? `id = ${onlyBiz}`
+  : refreshDays
+    ? `website IS NOT NULL AND website <> '' AND (email_search_at IS NULL OR email_search_at < NOW() - INTERVAL '${refreshDays} days')`
+    : `website IS NOT NULL AND website <> '' AND email_search_at IS NULL`;
+const targets = await many(`SELECT id, name, website FROM businesses WHERE ${where} ORDER BY id LIMIT ${limit}`);
+log.info(`harvesting emails from ${targets.length} businesses (concurrency=${CONCURRENCY})`);
+
+// Worker-pool: run N at once, each owns its own (host) so politeness is preserved.
+let cursor = 0;
+let processed = 0, found = 0;
+async function worker() {
+  while (true) {
+    const i = cursor++;
+    if (i >= targets.length) return;
+    const biz = targets[i];
+    try {
+      const n = await harvestOne(biz);
+      if (n) found += n;
+    } catch (err) { log.error('harvest failed', { biz: biz.id, err: err.message }); }
+    processed++;
+    if (processed % 25 === 0) log.info(`progress ${processed}/${targets.length} (${found} emails so far)`);
+  }
+}
+await Promise.all(Array.from({length: CONCURRENCY}, worker));
+await pool.end();
+log.info(`done — processed ${processed} businesses, found ${found} emails`);
+
+async function harvestOne(biz) {
+  const baseUrl = normalizeUrl(biz.website);
+  if (!baseUrl) return;
+  const found = new Map(); // email → {kind, context, source_url, method}
+  let pagesTried = 0;
+  for (const p of CONTACT_PATHS) {
+    if (found.size >= 5) break;
+    const url = absoluteUrl(baseUrl, p);
+    if (!url) continue;
+    let html;
+    try {
+      const r = await undiciFetch(url, { ...REQ_OPTS, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
+      pagesTried++;
+      if (!r.ok) continue;
+      const ct = r.headers.get('content-type') || '';
+      if (!/text\/html/i.test(ct)) continue;
+      // cap response size at 1MB; vet sites bigger than that are usually full SPAs that won't have emails inline
+      const buf = Buffer.from(await r.arrayBuffer());
+      html = buf.slice(0, 1024 * 1024).toString('utf8');
+    } catch { continue; }
+    extractFromHtml(html, url, found);
+    await sleep(150); // small intra-host pause
+  }
+
+  // Persist
+  const emails = [...found.entries()]
+    .filter(([e]) => isPlausibleEmail(e))
+    .filter(([e]) => !looksLikeJunk(e));
+  for (const [email, meta] of emails) {
+    await pool.query(
+      `INSERT INTO business_emails (business_id, email, email_kind, context_label, source_url, source_method, verification_status)
+       VALUES ($1,$2,$3,$4,$5,$6,'syntactically_valid')
+       ON CONFLICT (business_id, LOWER(email), source_url) DO NOTHING`,
+      [biz.id, email, meta.kind, meta.context, meta.source_url, meta.method]
+    );
+  }
+
+  // Pick best primary + backfill businesses.email
+  const primary = pickPrimary(emails);
+  await pool.query(
+    `UPDATE businesses
+     SET email_search_at = NOW(),
+         email_search_pages_tried = $2,
+         email = COALESCE(email, $3)
+     WHERE id = $1`,
+    [biz.id, pagesTried, primary]
+  );
+  if (emails.length > 0) log.info(`harvested ${biz.name}`, { found: emails.length, primary });
+  return emails.length;
+}
+
+// ─── extraction helpers ────────────────────────────────────────────────────
+
+function extractFromHtml(html, sourceUrl, foundMap) {
+  // 1. mailto: links (cheerio for href + nearby context)
+  let $;
+  try { $ = cheerio.load(html); } catch { $ = null; }
+  if ($) {
+    $('a[href^="mailto:"]').each((_, el) => {
+      const raw = ($(el).attr('href') || '').replace(/^mailto:/i, '').split('?')[0].trim();
+      const emails = raw.split(/[,;]/).map(s => s.trim()).filter(Boolean);
+      const context = collapseWhitespace($(el).closest('li, p, div, td, h1, h2, h3, h4, span').first().text() || $(el).text() || '').slice(0, 160);
+      for (const e of emails) {
+        const lower = e.toLowerCase();
+        if (!foundMap.has(lower)) {
+          foundMap.set(lower, { kind: classifyEmail(lower, context), context, source_url: sourceUrl, method: 'mailto' });
+        }
+      }
+    });
+    // 2. JSON-LD
+    $('script[type="application/ld+json"]').each((_, el) => {
+      try {
+        const j = JSON.parse($(el).text());
+        const list = Array.isArray(j) ? j : [j];
+        for (const node of list) walkJsonLdForEmails(node, foundMap, sourceUrl);
+      } catch {}
+    });
+  }
+  // 3. plaintext regex (post-decode HTML entities + common obfuscations)
+  const text = decodeObfuscation(stripScripts(html));
+  const RE = /[a-z0-9][a-z0-9._%+\-]{0,63}@[a-z0-9](?:[a-z0-9\-]{0,62}\.)+[a-z]{2,24}/gi;
+  for (const m of text.matchAll(RE)) {
+    const lower = m[0].toLowerCase();
+    if (foundMap.has(lower)) continue;
+    foundMap.set(lower, { kind: classifyEmail(lower, ''), context: '', source_url: sourceUrl, method: 'plaintext' });
+  }
+}
+
+function walkJsonLdForEmails(node, foundMap, sourceUrl) {
+  if (!node || typeof node !== 'object') return;
+  if (typeof node.email === 'string') {
+    for (const e of node.email.split(/[,;]/)) {
+      const lower = e.trim().toLowerCase();
+      if (lower && !foundMap.has(lower)) {
+        foundMap.set(lower, { kind: classifyEmail(lower, node.name || ''), context: node.name || '', source_url: sourceUrl, method: 'jsonld' });
+      }
+    }
+  }
+  for (const v of Object.values(node)) {
+    if (Array.isArray(v)) v.forEach(x => walkJsonLdForEmails(x, foundMap, sourceUrl));
+    else if (v && typeof v === 'object') walkJsonLdForEmails(v, foundMap, sourceUrl);
+  }
+}
+
+function decodeObfuscation(s) {
+  return s
+    .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n,10)))
+    .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n,16)))
+    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
+    .replace(/\s*\(at\)\s*|\s*\[at\]\s*|\s+at\s+/gi, '@')
+    .replace(/\s*\(dot\)\s*|\s*\[dot\]\s*|\s+dot\s+/gi, '.');
+}
+
+function stripScripts(html) {
+  return html
+    .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
+}
+function collapseWhitespace(s) { return (s || '').replace(/\s+/g, ' ').trim(); }
+
+function isPlausibleEmail(e) {
+  return /^[a-z0-9][a-z0-9._%+\-]{0,63}@[a-z0-9](?:[a-z0-9\-]{0,62}\.)+[a-z]{2,24}$/i.test(e) && e.length <= 254;
+}
+
+function looksLikeJunk(e) {
+  if (/(sentry\.io|wixpress|squarespace|godaddy|@example\.|@yourdomain|@your-)/i.test(e)) return true;
+  if (/(no-?reply|do-?not-?reply|postmaster|abuse|webmaster@)/i.test(e)) return true;
+  // image extensions caught by greedy regex
+  if (/\.(jpe?g|png|gif|webp|svg|ico|css|js)$/i.test(e)) return true;
+  // obvious placeholder patterns
+  if (/^(your(name|email)|user|name|info)@(example|domain|sample)\./i.test(e)) return true;
+  // Sentry-style hex prefixes
+  if (/^[a-f0-9]{16,}@/i.test(e)) return true;
+  return false;
+}
+
+function classifyEmail(email, context) {
+  const local = email.split('@')[0].toLowerCase();
+  if (ROLE_PREFIXES.some(p => local === p)) return 'role';
+  if (/^(intake|newpatient|appointments?|book(ing)?|schedule)/i.test(local)) return 'intake';
+  if (/^(admin|owner|office|manager|hr)/i.test(local)) return 'admin';
+  if (/^[a-z]+\.?[a-z]+$/.test(local)) return 'personal';   // jane.doe / janedoe / drjsmith
+  if (/dr[._-]?[a-z]+/i.test(local)) return 'personal';
+  if (context && /\b(dr\.?|doctor|owner|manager|director|dvm|vmd)\b/i.test(context)) return 'staff';
+  return 'unknown';
+}
+
+function pickPrimary(emails) {
+  if (!emails.length) return null;
+  const order = ['intake','role','admin','staff','personal','unknown'];
+  const scored = emails
+    .map(([e, m]) => ({ e, kind: m.kind, score: order.indexOf(m.kind) === -1 ? 99 : order.indexOf(m.kind) }))
+    .sort((a, b) => a.score - b.score);
+  return scored[0].e;
+}
+
+function normalizeUrl(u) {
+  try {
+    const parsed = new URL(u.startsWith('http') ? u : `https://${u}`);
+    return parsed.origin;
+  } catch { return null; }
+}
+function absoluteUrl(base, p) {
+  try { return new URL(p, base).href; } catch { return null; }
+}
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
diff --git a/src/ingest/nextdoor.js b/src/ingest/nextdoor.js
new file mode 100644
index 0000000..b520ce3
--- /dev/null
+++ b/src/ingest/nextdoor.js
@@ -0,0 +1,56 @@
+// Nextdoor ingestor — STUB (does not run by default).
+//
+// Honest status: Nextdoor has no public API and aggressively blocks
+// scrapers (Cloudflare bot-detection + login-required). Three free-ish
+// surfaces exist:
+//
+//   1. https://nextdoor.com/pages/<business-slug>/  — public business pages
+//      that show name/address/phone/hours but rate-limit anonymous fetches
+//      to a few per minute and 429 quickly. Useful for ENRICHING a known
+//      business, not for discovery.
+//
+//   2. https://nextdoor.com/sitemap.xml  — public sitemap of business
+//      pages and neighborhood profile pages. Free to crawl politely.
+//      → Best free starting point.
+//
+//   3. Logged-in Browserbase session — required for actual neighborhood
+//      feed posts ("recommendations" threads). NOT free (Browserbase has
+//      paid tiers; Nextdoor account also needed). Per Steve's "must be
+//      free" constraint, not implemented here.
+//
+// Free fallback that actually delivers similar signal: r/Patch (Patch.com
+// city-by-city local-news + business mentions) and Yelp's free public
+// review excerpts. These are wired in `src/ingest/patch.js` and the
+// Yelp public-fetch path inside reddit.js's secondary pass.
+//
+// To activate the Browserbase route later:
+//   - Add NEXTDOOR_EMAIL / NEXTDOOR_PASS to .env
+//   - Set USE_BROWSERBASE=1
+//   - Uncomment the Browserbase block below
+//
+// For now this file documents the architecture so a future run picks it up.
+
+import 'dotenv/config';
+import { log } from '../lib/log.js';
+
+const USE_BROWSERBASE = process.env.USE_BROWSERBASE === '1';
+
+async function ingestNextdoorSitemap() {
+  log.warn('Nextdoor sitemap ingest: not yet implemented (free path)');
+  log.info('  Plan: pull https://nextdoor.com/sitemap.xml, dedupe business URLs against businesses.website, polite crawl public pages with 5s delays, extract phone/hours via cheerio, log to raw_records as entity_type=nextdoor_business.');
+}
+
+async function ingestNextdoorBrowserbase() {
+  if (!USE_BROWSERBASE) {
+    log.warn('Nextdoor Browserbase ingest skipped: USE_BROWSERBASE != 1 (paid tier; not "free")');
+    return;
+  }
+  log.warn('Nextdoor Browserbase route: stub — add credentials and uncomment Browserbase block to enable.');
+}
+
+async function run() {
+  await ingestNextdoorSitemap();
+  await ingestNextdoorBrowserbase();
+}
+
+run().catch(e => { log.error('fatal', { err: e.message }); process.exit(1); });
diff --git a/src/ingest/osm_california.js b/src/ingest/osm_california.js
new file mode 100644
index 0000000..e3d2106
--- /dev/null
+++ b/src/ingest/osm_california.js
@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+// OSM Overpass California bulk crawl — primary source for everything pet-related.
+//
+// One Overpass query returns every vet clinic, pet store, shelter, boarder,
+// breeder, and dog park in California. Free. No API key. Just be polite.
+//
+// Run:
+//   node src/ingest/osm_california.js                # all categories
+//   node src/ingest/osm_california.js --only=veterinary,pet
+//
+// Maps OSM tags → our businesses.category:
+//   amenity=veterinary    → vet_clinic
+//   shop=pet|pets         → pet_store
+//   amenity=animal_shelter → shelter
+//   amenity=animal_boarding → boarding
+//   amenity=animal_breeding → breeder
+//   amenity=dog_park OR leisure=dog_park → dog_park
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { fetch as undiciFetch } from 'undici';
+import { pool } from '../lib/db.js';
+import { slugify } from '../lib/slug.js';
+import { log } from '../lib/log.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const RAW_DIR = path.resolve(__dirname, '..', '..', 'raw_html');
+fs.mkdirSync(RAW_DIR, { recursive: true });
+
+// Multiple endpoints for fallback — Overpass overload is real
+const ENDPOINTS = [
+  'https://overpass-api.de/api/interpreter',
+  'https://overpass.kumi.systems/api/interpreter',
+  'https://overpass.openstreetmap.ru/api/interpreter',
+];
+
+const TAG_MAP = {
+  veterinary:        'vet_clinic',
+  pet:               'pet_store',
+  pets:              'pet_store',
+  animal_shelter:    'shelter',
+  animal_boarding:   'boarding',
+  animal_breeding:   'breeder',
+  dog_park:          'dog_park',
+};
+
+const args = process.argv.slice(2);
+const onlyArg = args.find(a => a.startsWith('--only='));
+const only = onlyArg ? onlyArg.split('=')[1].split(',') : null;
+
+const sourceId = (await pool.query(
+  `SELECT id FROM sources WHERE source_name = 'OpenStreetMap Overpass'`
+)).rows[0]?.id;
+if (!sourceId) {
+  log.error('source row missing — run seed:sources first');
+  process.exit(1);
+}
+
+const QUERY = `
+[out:json][timeout:600];
+area["ISO3166-2"="US-CA"][admin_level=4]->.ca;
+(
+  nwr["amenity"="veterinary"](area.ca);
+  nwr["shop"="pet"](area.ca);
+  nwr["shop"="pets"](area.ca);
+  nwr["amenity"="animal_shelter"](area.ca);
+  nwr["amenity"="animal_boarding"](area.ca);
+  nwr["amenity"="animal_breeding"](area.ca);
+  nwr["amenity"="dog_park"](area.ca);
+  nwr["leisure"="dog_park"](area.ca);
+);
+out center tags;
+`.trim();
+
+let data = null;
+for (const ep of ENDPOINTS) {
+  try {
+    log.info(`querying ${ep}`);
+    const r = await undiciFetch(ep, {
+      method: 'POST',
+      headers: {
+        'content-type': 'application/x-www-form-urlencoded',
+        'user-agent': 'AnimalsDirectoryCABot/1.0 (+https://animalsdirectory.com/bot)'
+      },
+      body: 'data=' + encodeURIComponent(QUERY),
+      signal: AbortSignal.timeout(180000),
+    });
+    if (!r.ok) { log.warn(`endpoint ${ep} → HTTP ${r.status}`); continue; }
+    data = await r.json();
+    log.info(`got ${data.elements?.length || 0} elements from ${ep}`);
+    break;
+  } catch (err) {
+    log.warn(`endpoint ${ep} → ${err.message}`);
+  }
+}
+if (!data) { log.error('all overpass endpoints failed'); process.exit(1); }
+
+// Save raw response for compliance
+const rawPath = path.join(RAW_DIR, `osm-california-${new Date().toISOString().slice(0,10)}.json`);
+fs.writeFileSync(rawPath, JSON.stringify(data, null, 0));
+
+let kept = 0, skipped = 0;
+const bySlug = new Set();
+
+for (const el of data.elements || []) {
+  const tags = el.tags || {};
+  let cat = null;
+  if (only) {
+    // skip if user filtered
+    const tagKey = tags.amenity || tags.shop || tags.leisure;
+    if (!only.includes(tagKey)) { skipped++; continue; }
+  }
+  if (tags.amenity === 'veterinary')         cat = 'vet_clinic';
+  else if (tags.shop === 'pet' || tags.shop === 'pets') cat = 'pet_store';
+  else if (tags.amenity === 'animal_shelter') cat = 'shelter';
+  else if (tags.amenity === 'animal_boarding') cat = 'boarding';
+  else if (tags.amenity === 'animal_breeding') cat = 'breeder';
+  else if (tags.amenity === 'dog_park' || tags.leisure === 'dog_park') cat = 'dog_park';
+  else { skipped++; continue; }
+
+  const name = (tags.name || tags['name:en'] || '').trim();
+  if (!name) { skipped++; continue; }   // unnamed nodes are noise
+
+  const lat = el.lat ?? el.center?.lat;
+  const lng = el.lon ?? el.center?.lon;
+  const addr = [tags['addr:housenumber'], tags['addr:street']].filter(Boolean).join(' ').trim() || null;
+  const city = tags['addr:city'] || null;
+  const state = (tags['addr:state'] || 'CA').toUpperCase();
+  const zip = tags['addr:postcode'] || null;
+  const phone = tags.phone || tags['contact:phone'] || null;
+  const website = tags.website || tags['contact:website'] || tags.url || null;
+  const email = tags.email || tags['contact:email'] || null;
+  const opening_hours = tags.opening_hours || null;
+
+  let slug = slugify(`${name}-${city || 'ca'}-${state}`);
+  // dedupe within this run if the same business appears as both a node and a way
+  if (bySlug.has(`${cat}/${slug}`)) { skipped++; continue; }
+  bySlug.add(`${cat}/${slug}`);
+
+  try {
+    const r = await pool.query(`
+      INSERT INTO businesses
+        (category, name, slug, address, city, state, zip, latitude, longitude,
+         phone, email, website, source_url, source_id, hours_json)
+      VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+      ON CONFLICT (category, slug) DO UPDATE SET
+        latitude  = COALESCE(businesses.latitude,  EXCLUDED.latitude),
+        longitude = COALESCE(businesses.longitude, EXCLUDED.longitude),
+        phone     = COALESCE(businesses.phone,     EXCLUDED.phone),
+        email     = COALESCE(businesses.email,     EXCLUDED.email),
+        website   = COALESCE(businesses.website,   EXCLUDED.website),
+        address   = COALESCE(businesses.address,   EXCLUDED.address),
+        city      = COALESCE(businesses.city,      EXCLUDED.city),
+        zip       = COALESCE(businesses.zip,       EXCLUDED.zip),
+        hours_json = COALESCE(businesses.hours_json, EXCLUDED.hours_json),
+        updated_at = NOW()
+      RETURNING (xmax = 0) AS inserted`,
+      [cat, name, slug, addr, city, state, zip, lat, lng,
+       phone, email, website,
+       `https://www.openstreetmap.org/${el.type}/${el.id}`,
+       sourceId,
+       opening_hours ? JSON.stringify({ raw: opening_hours }) : null]
+    );
+    if (r.rows[0]?.inserted) kept++;
+  } catch (err) {
+    log.warn(`insert failed for ${name}`, { err: err.message });
+    skipped++;
+  }
+}
+
+log.info(`OSM CA ingest done: ${kept} new, ${skipped} skipped, raw saved → ${rawPath}`);
+
+// Summary by category
+const counts = await pool.query(`
+  SELECT category, COUNT(*)::int AS n
+  FROM businesses
+  WHERE state = 'CA'
+  GROUP BY category ORDER BY n DESC`);
+console.log('\nCalifornia inventory:');
+for (const row of counts.rows) console.log(`  ${row.category.padEnd(20)} ${row.n}`);
+
+await pool.end();
diff --git a/src/ingest/reddit.js b/src/ingest/reddit.js
new file mode 100644
index 0000000..2731dcc
--- /dev/null
+++ b/src/ingest/reddit.js
@@ -0,0 +1,168 @@
+// Reddit ingestor — 100% free, no API key.
+// Uses Reddit's public .json endpoints (rate-limited to ~60 req/min anonymous).
+// Pipeline:
+//   1. For each subreddit (city subs + pet care subs), fetch latest N posts as JSON
+//   2. Triage with gemma3:4b: "is this post about a pet-care business or service?"
+//   3. Extract with qwen3:14b: business names, services, city/state, sentiment
+//   4. Insert raw_records (idempotent on hash) and upsert mentions into businesses
+//
+// Run:
+//   node src/ingest/reddit.js                       # default subreddit set
+//   SUBREDDITS=LosAngeles,nyc node src/ingest/reddit.js
+//   LIMIT=25 node src/ingest/reddit.js              # smaller per-sub fetch
+//
+// Cron via launchd: ~/Library/LaunchAgents/com.steve.animals-reddit-weekly.plist
+
+import 'dotenv/config';
+import crypto from 'node:crypto';
+import { pool, query, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+import { triageYesNo, extractJson } from '../lib/llm.js';
+
+const UA = 'animals-directory-research/0.1 (contact: steve@designerwallcoverings.com)';
+
+const PET_KEYWORDS = /\b(vet|veterinarian|veterinary|animal hospital|emergency vet|pet|dog|cat|puppy|kitten|groomer|grooming|boarding|kennel|trainer|training|shelter|rescue|adopt|breeder|pet store|pet sitter|dog walker|daycare)\b/i;
+
+// Default subreddits — top US metros + pet-care subs.
+const DEFAULT_SUBREDDITS = [
+  // Pet-care subs (recommendations come up here daily)
+  'AskVet', 'dogs', 'cats', 'Pets', 'Petloss', 'puppy101', 'CatAdvice',
+  // Top metro city subs
+  'LosAngeles', 'AskLosAngeles', 'sandiego', 'sanfrancisco', 'BayArea',
+  'nyc', 'AskNYC', 'longisland',
+  'chicago', 'houston', 'dallas', 'austin', 'seattle', 'Portland', 'Denver',
+  'atlanta', 'Boston', 'philadelphia', 'phoenix', 'Miami', 'StPetersburgFL',
+  'Minneapolis', 'StLouis', 'kansascity', 'Charlotte', 'nashville',
+  'baltimore', 'WashingtonDC', 'pittsburgh', 'cleveland', 'detroit',
+  'Cincinnati', 'columbus', 'indianapolis', 'milwaukee', 'okc', 'lasvegas',
+  'Sacramento', 'orlando', 'Tampa', 'Jacksonville', 'NewOrleans',
+];
+
+const SUBREDDITS = (process.env.SUBREDDITS || DEFAULT_SUBREDDITS.join(','))
+  .split(',').map(s => s.trim()).filter(Boolean);
+const LIMIT = parseInt(process.env.LIMIT || '50', 10);
+const SLEEP_MS = parseInt(process.env.SLEEP_MS || '1100', 10); // ~55 req/min
+const DRY = process.env.DRY === '1';
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
+
+async function ensureSource() {
+  const existing = await one(`SELECT id FROM sources WHERE source_name = 'reddit'`);
+  if (existing) return existing.id;
+  const r = await one(
+    `INSERT INTO sources (source_name, source_type, base_url, terms_notes, allowed_method, rate_limit_rps, robots_txt_url, last_checked_at)
+     VALUES ('reddit', 'other', 'https://www.reddit.com', 'Public .json endpoints; ~60 req/min anonymous; UGC under reddit.com/help/contentpolicy. Used as discovery/enrichment signal, not as the primary record.', 'crawl', 1.0, 'https://www.reddit.com/robots.txt', NOW())
+     RETURNING id`);
+  log.info('created sources row', { source: 'reddit', id: r.id });
+  return r.id;
+}
+
+async function fetchSub(sub, limit) {
+  const url = `https://www.reddit.com/r/${encodeURIComponent(sub)}/new.json?limit=${limit}&raw_json=1`;
+  const r = await fetch(url, { headers: { 'user-agent': UA, 'accept': 'application/json' } });
+  if (r.status === 429) { await sleep(5000); return fetchSub(sub, limit); }
+  if (!r.ok) { log.warn(`reddit ${sub} ${r.status}`); return []; }
+  const j = await r.json();
+  return (j?.data?.children || []).map(c => c.data);
+}
+
+// LLM helpers — local Ollama (free, no quota)
+async function isPetCareRelevant(post) {
+  const text = `${post.title}\n\n${post.selftext || ''}`.slice(0, 4000);
+  // Cheap regex prefilter — kills 80% of noise without an LLM call
+  if (!PET_KEYWORDS.test(text)) return false;
+  // Triage with gemma3:4b — has the post any actionable info about a pet-care business?
+  return triageYesNo(
+    'Does this post mention or ask about a specific pet-care business, vet, groomer, trainer, shelter, breeder, boarding facility, or pet store? It must reference a real provider, not just abstract advice.',
+    text
+  );
+}
+
+async function extractMentions(post) {
+  const text = `Title: ${post.title}\n\nBody: ${post.selftext || ''}`;
+  return extractJson(
+    'You extract mentions of real-world pet-care businesses from Reddit posts. ' +
+    'Only return businesses the author or commenter has personally used or is asking about. ' +
+    'Skip generic advice. Skip national chains unless a specific branch (city) is named. ' +
+    'Use sentiment values: "positive" | "negative" | "neutral" | "asking" (asking for recs).',
+    text,
+    `{
+  "mentions": [
+    {
+      "business_name": "string",
+      "service_type": "vet_clinic | emergency_vet | groomer | trainer | boarding | daycare | pet_store | shelter | breeder | other",
+      "city": "string or null",
+      "state": "2-letter US state or null",
+      "sentiment": "positive | negative | neutral | asking",
+      "context": "one short sentence summary"
+    }
+  ]
+}`
+  );
+}
+
+async function ingestPost(post, subId, sub) {
+  const sourceUrl = `https://www.reddit.com${post.permalink}`;
+  const hash = sha256(sourceUrl);
+
+  // Idempotency check
+  const existing = await one(`SELECT id FROM raw_records WHERE hash = $1`, [hash]);
+  if (existing) return { skipped: true };
+
+  if (!await isPetCareRelevant(post)) {
+    if (DRY) log.debug('not relevant', { sub, title: post.title.slice(0, 60) });
+    return { not_relevant: true };
+  }
+
+  const mentions = await extractMentions(post);
+  if (!mentions || !Array.isArray(mentions.mentions) || mentions.mentions.length === 0) {
+    return { no_mentions: true };
+  }
+
+  if (DRY) {
+    log.info('DRY mention', { sub, title: post.title.slice(0, 80), mentions: mentions.mentions });
+    return { dry: true };
+  }
+
+  await query(
+    `INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, http_status, hash)
+     VALUES ($1, $2, 'reddit_post', $3, $4, 200, $5)`,
+    [subId, sourceUrl, String(post.id), JSON.stringify({ post, mentions: mentions.mentions, sub }), hash]
+  );
+  log.info('ingested post', {
+    sub, title: post.title.slice(0, 80),
+    mention_count: mentions.mentions.length,
+    sentiments: mentions.mentions.map(m => m.sentiment).join(',')
+  });
+  return { ingested: true, mention_count: mentions.mentions.length };
+}
+
+async function run() {
+  const sourceId = await ensureSource();
+  const stats = { subs: 0, posts: 0, ingested: 0, mentions: 0, skipped: 0, not_relevant: 0, no_mentions: 0, errors: 0 };
+  for (const sub of SUBREDDITS) {
+    stats.subs++;
+    log.info('fetching subreddit', { sub, limit: LIMIT });
+    const posts = await fetchSub(sub, LIMIT);
+    stats.posts += posts.length;
+    for (const post of posts) {
+      try {
+        const r = await ingestPost(post, sourceId, sub);
+        if (r.ingested) { stats.ingested++; stats.mentions += r.mention_count; }
+        else if (r.skipped) stats.skipped++;
+        else if (r.not_relevant) stats.not_relevant++;
+        else if (r.no_mentions) stats.no_mentions++;
+      } catch (e) {
+        stats.errors++;
+        log.error('ingest failed', { sub, post_id: post.id, err: e.message });
+      }
+      await sleep(50); // small breath between LLM calls; rate-limit is on reddit fetch
+    }
+    await sleep(SLEEP_MS); // reddit rate-limit
+  }
+  log.info('reddit ingest complete', stats);
+  await pool.end();
+}
+
+run().catch(e => { log.error('fatal', { err: e.message, stack: e.stack }); process.exit(1); });
diff --git a/src/lib/llm.js b/src/lib/llm.js
new file mode 100644
index 0000000..c392180
--- /dev/null
+++ b/src/lib/llm.js
@@ -0,0 +1,59 @@
+// Local Ollama wrapper. 100% free, runs against http://localhost:11434.
+//
+// IMPORTANT — single-model default: Ollama on Mac Studio 2 only fits ONE 12-14B
+// model in GPU memory at a time. If TRIAGE and EXTRACT use different models,
+// every post pays a 30-60s model-swap tax. Default both to qwen3:14b (already
+// the project standard per memory) so the model warms once and stays warm.
+//
+// Override per-call via env if you have headroom for two models loaded at once:
+//   LLM_TRIAGE=gemma3:4b LLM_EXTRACT=qwen3:14b npm run ingest:reddit
+
+const OLLAMA_HOST   = process.env.OLLAMA_HOST   || 'http://localhost:11434';
+const TRIAGE_MODEL  = process.env.LLM_TRIAGE    || 'qwen3:14b';
+const EXTRACT_MODEL = process.env.LLM_EXTRACT   || 'qwen3:14b';
+const KEEP_ALIVE    = process.env.LLM_KEEPALIVE || '15m';
+
+async function callOllama(model, prompt, opts = {}) {
+  const r = await fetch(`${OLLAMA_HOST}/api/generate`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({
+      model, prompt, stream: false,
+      keep_alive: KEEP_ALIVE,                  // hold the model in GPU between calls
+      options: { temperature: opts.temperature ?? 0.2, num_predict: opts.num_predict ?? 1024 },
+      format: opts.json ? 'json' : undefined,
+    }),
+  });
+  if (!r.ok) throw new Error(`ollama ${model} ${r.status}: ${await r.text()}`);
+  const j = await r.json();
+  return j.response;
+}
+
+// Triage: ask gemma3:4b a yes/no question. Returns boolean.
+export async function triageYesNo(question, text) {
+  const prompt =
+    `You are a fast triage classifier. Answer with one word: YES or NO.\n` +
+    `Question: ${question}\n` +
+    `Text:\n"""${text.slice(0, 4000)}"""\n` +
+    `Answer:`;
+  const out = (await callOllama(TRIAGE_MODEL, prompt, { temperature: 0, num_predict: 5 })).trim().toUpperCase();
+  return out.startsWith('Y');
+}
+
+// Extract: ask qwen3:14b for structured JSON. Returns parsed object or null.
+export async function extractJson(systemPrompt, text, schemaHint) {
+  const prompt =
+    `${systemPrompt}\n\n` +
+    `Output valid JSON only, matching this shape:\n${schemaHint}\n\n` +
+    `Input text:\n"""${text.slice(0, 8000)}"""\n\n` +
+    `JSON output:`;
+  const raw = await callOllama(EXTRACT_MODEL, prompt, { temperature: 0.1, num_predict: 1500, json: true });
+  try { return JSON.parse(raw); }
+  catch {
+    // qwen sometimes wraps in code fence; strip and retry
+    const stripped = raw.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
+    try { return JSON.parse(stripped); } catch { return null; }
+  }
+}
+
+export const MODELS = { TRIAGE_MODEL, EXTRACT_MODEL };
diff --git a/src/server/index.js b/src/server/index.js
index ef1cb14..19dc6fb 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -45,6 +45,45 @@ const CAT_TO_DB = {
 
 app.get('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }));
 
+// Near-me — used by /static/js/geo.js after browser geolocation resolves.
+// Haversine in SQL with a bbox prefilter so we don't trig over every business.
+app.get('/api/near-me', async (req, res) => {
+  const lat = parseFloat(req.query.lat);
+  const lng = parseFloat(req.query.lng);
+  const radiusMi = Math.min(100, Math.max(1, parseFloat(req.query.radius_mi || '25')));
+  if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+    return res.status(400).json({ error: 'lat and lng required' });
+  }
+  const dLat = radiusMi / 69.0;
+  const dLng = radiusMi / (69.0 * Math.cos(lat * Math.PI / 180));
+  const sql = `
+    WITH params AS (
+      SELECT $1::float8 AS lat, $2::float8 AS lng, $3::float8 AS dlat, $4::float8 AS dlng
+    ), bbox AS (
+      SELECT b.* FROM businesses b, params p
+      WHERE b.opt_out_flag = FALSE
+        AND b.latitude  BETWEEN p.lat - p.dlat AND p.lat + p.dlat
+        AND b.longitude BETWEEN p.lng - p.dlng AND p.lng + p.dlng
+    )
+    SELECT b.id, b.name, b.category, b.city, b.state, b.latitude, b.longitude,
+           3958.8 * 2 * asin(sqrt(
+             power(sin(radians((b.latitude - p.lat) / 2)), 2) +
+             cos(radians(p.lat)) * cos(radians(b.latitude)) *
+             power(sin(radians((b.longitude - p.lng) / 2)), 2)
+           )) AS distance_mi
+    FROM bbox b, params p
+    ORDER BY distance_mi ASC
+    LIMIT 24
+  `;
+  try {
+    const rows = (await many(sql, [lat, lng, dLat, dLng])).filter(r => r.distance_mi <= radiusMi);
+    res.json({ lat, lng, radius_mi: radiusMi, count: rows.length, results: rows });
+  } catch (e) {
+    log.error('near-me failed', { err: e.message });
+    res.status(500).json({ error: 'lookup failed' });
+  }
+});
+
 // Home
 app.get('/', async (_req, res) => {
   const breeds = await many(`
@@ -95,11 +134,12 @@ app.get('/:cat', async (req, res, next) => {
   const dbCat = CAT_TO_DB[req.params.cat];
   if (!dbCat) return next();
   const businesses = await many(`
-    SELECT id, name, slug, city, state, zip, latitude, longitude, rating, review_count, website
+    SELECT id, name, slug, city, state, zip, latitude, longitude, rating, review_count,
+           website, email, phone
     FROM businesses
     WHERE category=$1 AND opt_out_flag=FALSE
-    ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST
-    LIMIT 200`, [dbCat]);
+    ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST, name
+    LIMIT 2000`, [dbCat]);
   const ad = await pickAdForPage({ pagePath: req.path, keywords: [req.params.cat], category: dbCat });
   res.send(renderCategory({ category: req.params.cat, dbCategory: dbCat, businesses, ad }));
 });
diff --git a/src/server/render.js b/src/server/render.js
index fb42611..d304b94 100644
--- a/src/server/render.js
+++ b/src/server/render.js
@@ -19,6 +19,8 @@ function head(title, extra = '') {
 <title>${esc(title)} · AnimalsDirectory</title>
 <link rel="stylesheet" href="/static/css/site.css">
 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
+<script defer src="/static/js/sort-table.js"></script>
+<script defer src="/static/js/geo.js"></script>
 ${extra}
 </head><body>`;
 }
@@ -99,10 +101,17 @@ function leadForm() {
 }
 
 export function renderHome({ breeds, counts, categories }) {
-  const catCards = categories.map(c => `
+  // url-slug ('emergency-vets') → db-key ('emergency_vet'); the slugs use plurals,
+  // the db uses singular for some. Map explicitly so counts always render.
+  const URL_TO_DB = {
+    'vets':'vet_clinic','emergency-vets':'emergency_vet','specialty-hospitals':'specialty_hospital',
+    'groomers':'groomer','pet-stores':'pet_store','shelters':'shelter','breeders':'breeder',
+    'trainers':'trainer','boarding':'boarding','daycare':'daycare','dog-parks':'dog_park',
+  };
+  const catCards = categories.filter(c => (counts[URL_TO_DB[c]]||0) > 0).map(c => `
     <a href="/${c}" class="cat-card">
       <strong>${esc(c.replace(/-/g,' '))}</strong>
-      <span>${esc((counts[c.replace(/-/g,'_')]||0).toLocaleString())} listings</span>
+      <span>${esc((counts[URL_TO_DB[c]]||0).toLocaleString())} listings</span>
     </a>`).join('');
   const breedCards = breeds.map(b => `
     <a href="/breeds/${esc(b.slug)}" class="breed-card">
@@ -115,8 +124,13 @@ export function renderHome({ breeds, counts, categories }) {
   <section class="hero">
     <h1>Everything for your animal — in one place.</h1>
     <p>Browse 500+ breeds. Find vets, groomers, pet stores, trainers, breeders, and shelters near you. With maps. With reviews. Always free for pet owners.</p>
+    <div id="geo-banner" class="geo-banner" aria-live="polite"><span class="geo-pin">📍</span><span>Locating you…</span></div>
     <a class="cta-big" href="#lead-form">Get matched with a pro →</a>
   </section>
+  <section class="near-me">
+    <h2>Nearest to you</h2>
+    <div id="near-me-list" class="near-grid"><p class="empty">Sharing your location loads the closest 24 listings…</p></div>
+  </section>
   <section class="cat-grid">${catCards}</section>
   <h2>Popular breeds</h2>
   <section class="breed-grid">${breedCards}</section>
@@ -179,33 +193,48 @@ export function renderBreed({ breed, relatedVets, ad }) {
 }
 
 export function renderCategory({ category, dbCategory, businesses, ad }) {
-  const list = businesses.map(b => `
-    <li>
-      <a href="/clinic/${b.id}"><strong>${esc(b.name)}</strong></a>
-      <small>${esc(b.city||'')}, ${esc(b.state||'')} ${b.zip?`· ${esc(b.zip)}`:''}${b.rating?` · ★ ${b.rating} (${b.review_count||0})`:''}</small>
-      ${b.website ? `<a class="ext" href="${esc(b.website)}" rel="nofollow noopener" target="_blank">website ↗</a>` : ''}
-    </li>`).join('');
-  // mini-map for the first ~50 with coords
-  const points = businesses.filter(b=>b.latitude&&b.longitude).slice(0,200).map(b => ({id:b.id,name:b.name,lat:Number(b.latitude),lng:Number(b.longitude)}));
+  const rows = businesses.map(b => `
+    <tr>
+      <td><a href="/clinic/${b.id}"><strong>${esc(b.name)}</strong></a></td>
+      <td>${esc(b.city||'')}</td>
+      <td>${esc(b.state||'')}</td>
+      <td>${esc(b.zip||'')}</td>
+      <td>${b.phone ? `<a href="tel:${esc(b.phone)}">${esc(b.phone)}</a>` : ''}</td>
+      <td>${b.email ? `<a href="mailto:${esc(b.email)}">${esc(b.email)}</a>` : ''}</td>
+      <td>${b.website ? `<a href="${esc(b.website)}" rel="nofollow noopener" target="_blank">site ↗</a>` : ''}</td>
+      <td data-sort="num">${b.rating ?? ''}</td>
+      <td data-sort="num">${b.review_count ?? ''}</td>
+    </tr>`).join('');
+  // mini-map for the first ~200 with coords
+  const geoPoints = businesses.filter(b=>b.latitude&&b.longitude);
+  const points = geoPoints.slice(0,200).map(b => ({id:b.id,name:b.name,lat:Number(b.latitude),lng:Number(b.longitude)}));
+  const mapCapNotice = geoPoints.length > 200
+    ? `<p class="muted" style="font-size:.85em;margin:.25em 0 0">Map shows first 200 of ${geoPoints.length} geocoded listings — full table below.</p>`
+    : '';
   return head(`${category.replace(/-/g,' ')} directory`) + nav() + `
 <main class="container">
   <h1>${esc(category.replace(/-/g,' '))} (${businesses.length})</h1>
-  <div id="cat-map" class="map" style="height:360px"></div>
+  <div id="cat-map" class="map" style="height:380px"></div>
+  ${mapCapNotice}
   ${adSlot(ad, 'header', `/${category}`)}
-  <ul class="business-list">${list || '<li class="muted">No listings yet — ingest pending.</li>'}</ul>
+  ${businesses.length ? `<table data-sortable class="biz-table"><thead><tr>
+    <th>Name</th><th>City</th><th>State</th><th>ZIP</th><th>Phone</th><th>Email</th><th>Website</th><th data-sort="num">Rating</th><th data-sort="num">Reviews</th>
+  </tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted">No listings yet — ingest pending.</p>'}
   ${leadForm()}
 </main>
 <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
 <script>
+(function(){
+  const el = document.getElementById('cat-map');
+  if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load (network blocked?).</p>'; return; }
   const pts = ${JSON.stringify(points)};
-  if (pts.length) {
-    const map = L.map('cat-map').setView([pts[0].lat, pts[0].lng], 9);
-    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap contributors', maxZoom:18 }).addTo(map);
-    const grp = L.featureGroup(pts.map(p => L.marker([p.lat,p.lng]).bindPopup('<a href="/clinic/'+p.id+'">'+p.name+'</a>'))).addTo(map);
-    if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.2));
-  } else {
-    document.getElementById('cat-map').innerHTML = '<p class="muted" style="padding:1em">Map shows here once we have geocoded listings.</p>';
-  }
+  if (!pts.length) { el.innerHTML = '<p class="muted" style="padding:1em">Map shows here once we have geocoded listings.</p>'; return; }
+  const map = L.map('cat-map').setView([pts[0].lat, pts[0].lng], 9);
+  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap contributors', maxZoom:18 }).addTo(map);
+  const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:8, color:'#0f4d3a', fillColor:'#0f4d3a', fillOpacity:0.85, weight:2 }).bindPopup('<strong>'+p.name+'</strong><br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
+  if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.2));
+  setTimeout(() => map.invalidateSize(), 100);
+})();
 </script>` + footer() + '</body></html>';
 }
 
@@ -219,9 +248,14 @@ export function renderBusiness({ biz, audit, ad }) {
   const map = (biz.latitude && biz.longitude) ? `<div id="biz-map" class="map" style="height:300px"></div>
     <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
     <script>
+    (function(){
+      const el = document.getElementById('biz-map');
+      if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load.</p>'; return; }
       const m = L.map('biz-map').setView([${biz.latitude}, ${biz.longitude}], 14);
       L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(m);
-      L.marker([${biz.latitude}, ${biz.longitude}]).addTo(m).bindPopup(${JSON.stringify(biz.name)});
+      L.circleMarker([${biz.latitude}, ${biz.longitude}], { radius:10, color:'#0f4d3a', fillColor:'#c97a3e', fillOpacity:0.9, weight:3 }).addTo(m).bindPopup(${JSON.stringify(biz.name)}).openPopup();
+      setTimeout(() => m.invalidateSize(), 100);
+    })();
     </script>` : '';
   return head(biz.name) + nav() + `
 <main class="container biz-page">
@@ -259,22 +293,25 @@ export function renderCityMap({ city, state, businesses, ad }) {
 </main>
 <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
 <script>
+(function(){
+  const el = document.getElementById('city-map');
+  if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load.</p>'; return; }
   const pts = ${JSON.stringify(points)};
-  if (!pts.length) { document.getElementById('city-map').innerHTML = '<p>No listings yet for this city.</p>'; }
-  else {
-    const colors = { vet_clinic:'#0f4d3a', emergency_vet:'#c0382b', groomer:'#c97a3e', pet_store:'#3a6ea5', shelter:'#7e57c2', breeder:'#5d4037', trainer:'#388e3c', boarding:'#0288d1', dog_park:'#6a994e' };
-    const map = L.map('city-map').setView([pts[0].lat, pts[0].lng], 12);
-    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
-    const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:7, color:colors[p.cat]||'#333', fillOpacity:0.85 }).bindPopup('<strong>'+p.name+'</strong><br>'+p.cat.replace(/_/g,' ')+'<br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
-    if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.15));
-  }
+  if (!pts.length) { el.innerHTML = '<p>No listings yet for this city.</p>'; return; }
+  const colors = { vet_clinic:'#0f4d3a', emergency_vet:'#c0382b', specialty_hospital:'#5e35b1', groomer:'#c97a3e', pet_store:'#3a6ea5', shelter:'#7e57c2', breeder:'#5d4037', trainer:'#388e3c', boarding:'#0288d1', daycare:'#26a69a', dog_park:'#6a994e' };
+  const map = L.map('city-map').setView([pts[0].lat, pts[0].lng], 12);
+  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
+  const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:8, color:colors[p.cat]||'#333', fillColor:colors[p.cat]||'#333', fillOpacity:0.85, weight:2 }).bindPopup('<strong>'+p.name+'</strong><br><span style="color:#666">'+p.cat.replace(/_/g,' ')+'</span><br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
+  if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.15));
+  setTimeout(() => map.invalidateSize(), 100);
+})();
 </script>` + footer() + '</body></html>';
 }
 
 export function renderLeadForm() { return head('Get matched') + nav() + `<main class="container">${leadForm()}</main>` + footer() + '</body></html>'; }
 
 export function renderAdmin({ upgradeQ, leadsQ, auditQ }) {
-  const tbl = (rows, fields) => rows.length ? `<table><thead><tr>${fields.map(f=>`<th>${f}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${fields.map(f=>`<td>${esc(r[f]||'')}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<p class="muted">empty</p>';
+  const tbl = (rows, fields) => rows.length ? `<table data-sortable><thead><tr>${fields.map(f=>`<th>${f}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${fields.map(f=>`<td>${esc(r[f]||'')}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<p class="muted">empty</p>';
   return head('Admin') + nav() + `<main class="container">
     <h1>Admin queue</h1>
     <h2>Upgrade orders</h2>${tbl(upgradeQ,['id','business_name','email','status','created_at'])}

← 474af3c init: project animals scaffold (schema + viewer + audit + mo  ·  back to Animals  ·  fix(community): debate-driven fixes for visibility, comments 95e913a →