[object Object]

← back to Restaurant Directory

Remove all health-code/inspection/violation rendering and drop tables

bdac86f814687b915239770b4dacf3b120b4c3ea · 2026-05-08 00:09:56 -0700 · Steve

Steve directive 2026-05-08: 'remove all the health code details - render +
drop DB.' Backup taken at backups/inspection-violation-20260507-235846.sql
(local tables already empty before drop).

Removed:
- views/list.ejs: grade column, ?grade= select, grade-pill rendering, grade
  param dropped from URLSearchParams (prev/next pagination)
- views/city.ejs: grade-row tally header, grade-stat link blocks, grade pill
  on rest-meta, 'health grades' wording from lede
- views/detail.ejs: entire grade-block, inspections section + table,
  inspector-notes / violation-block, risk_tier dt/dd, 'Health grade' meta
- views/partials/foot.ejs: 'health-permitted' + 'Inspection grades reflect'
- views/partials/head.ejs: 'health-permitted' + 'Health grade' from default
  meta description
- src/api/server.ts: i.grade LATERAL JOIN in LIST_QUERY, ?grade= filter on /
  and /api/restaurants, grades query in /c/:slug, inspections+violations
  queries in /r/:slug, score / last_inspection / risk_tier from selects,
  grade prop from res.render calls, grade from FacilityRow type
- scripts/weekly-ingest.sh: drop ingest-inspections.ts call; drop -t inspection
  -t violation from pg_dump; rewrite header doc

DB (LOCAL):
- DROP TABLE inspection CASCADE  (was 0 rows)
- DROP TABLE violation CASCADE  (was 0 rows)

Cron:
- launchctl unload com.steve.lacountyeats-weekly (then reloaded after commit)

Smokes (PORT=9743):
- / 200 in 4.8s, 0 health refs
- /c/los-angeles 200 in 7.2s, 0 health refs
- /r/<slug> 200 in 49ms, 0 health refs
- /api/restaurants?grade=A returns count (param now no-op)

Pending on Steve:
- Push to Kamatera prod (Tailscale auth blocked)
- Drop inspection + violation on prod PG
- Optional: delete data/la-eh-violations.csv raw file

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

Files touched

Diff

commit bdac86f814687b915239770b4dacf3b120b4c3ea
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 8 00:09:56 2026 -0700

    Remove all health-code/inspection/violation rendering and drop tables
    
    Steve directive 2026-05-08: 'remove all the health code details - render +
    drop DB.' Backup taken at backups/inspection-violation-20260507-235846.sql
    (local tables already empty before drop).
    
    Removed:
    - views/list.ejs: grade column, ?grade= select, grade-pill rendering, grade
      param dropped from URLSearchParams (prev/next pagination)
    - views/city.ejs: grade-row tally header, grade-stat link blocks, grade pill
      on rest-meta, 'health grades' wording from lede
    - views/detail.ejs: entire grade-block, inspections section + table,
      inspector-notes / violation-block, risk_tier dt/dd, 'Health grade' meta
    - views/partials/foot.ejs: 'health-permitted' + 'Inspection grades reflect'
    - views/partials/head.ejs: 'health-permitted' + 'Health grade' from default
      meta description
    - src/api/server.ts: i.grade LATERAL JOIN in LIST_QUERY, ?grade= filter on /
      and /api/restaurants, grades query in /c/:slug, inspections+violations
      queries in /r/:slug, score / last_inspection / risk_tier from selects,
      grade prop from res.render calls, grade from FacilityRow type
    - scripts/weekly-ingest.sh: drop ingest-inspections.ts call; drop -t inspection
      -t violation from pg_dump; rewrite header doc
    
    DB (LOCAL):
    - DROP TABLE inspection CASCADE  (was 0 rows)
    - DROP TABLE violation CASCADE  (was 0 rows)
    
    Cron:
    - launchctl unload com.steve.lacountyeats-weekly (then reloaded after commit)
    
    Smokes (PORT=9743):
    - / 200 in 4.8s, 0 health refs
    - /c/los-angeles 200 in 7.2s, 0 health refs
    - /r/<slug> 200 in 49ms, 0 health refs
    - /api/restaurants?grade=A returns count (param now no-op)
    
    Pending on Steve:
    - Push to Kamatera prod (Tailscale auth blocked)
    - Drop inspection + violation on prod PG
    - Optional: delete data/la-eh-violations.csv raw file
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 scripts/weekly-ingest.sh        | 100 ++++++++++++
 src/api/server.ts               | 339 ++++++++++++++++++++++++++++++++++++++++
 src/web/views/city.ejs          |  49 ++++++
 src/web/views/detail.ejs        |  45 ++++++
 src/web/views/list.ejs          | 104 ++++++++++++
 src/web/views/partials/foot.ejs |  11 ++
 src/web/views/partials/head.ejs |  37 +++++
 7 files changed, 685 insertions(+)

diff --git a/scripts/weekly-ingest.sh b/scripts/weekly-ingest.sh
new file mode 100755
index 0000000..e3e171a
--- /dev/null
+++ b/scripts/weekly-ingest.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+#
+# Weekly LA County Eats ingest cron.
+#
+# Runs Mondays 04:00 PT via launchd com.steve.lacountyeats-weekly.plist on Mac
+# Studio 2 (LOCAL job — touches local PG, then dumps + restores to Kamatera,
+# then emails Steve via George localhost:9850).
+#
+# Steps:
+#   1. Pull latest LA County DPH inventory CSV from ArcGIS
+#   2. Capture pre-pull facility count
+#   3. Run ingest-inventory.ts (idempotent UPSERT keyed on FACILITY_ID)
+#   4. Diff: how many NEW facilities (first_seen_at > last week)?
+#   5. Diff: how many UPDATED facilities (last_updated_at > last week)?
+#   6. dump+restore facility tables to Kamatera
+#   7. Trigger cuisine enrichment of any new facilities (background, ~1.2s each)
+#   8. Email Steve with the week's diff via George
+#   9. Standing-rule alert: if failure rate > 10%, mark email subject 'WARNING'
+#
+# Inspections + violations were removed from the public site 2026-05-08
+# (Steve directive: "remove all the health code details, render + drop DB").
+# scripts/ingest-inspections.ts is retained for archival but no longer fires.
+#
+# Logs go to ~/Projects/restaurant-directory/logs/weekly-YYYYMMDD.log
+set -euo pipefail
+
+cd /Users/stevestudio2/Projects/restaurant-directory
+mkdir -p logs
+LOG="logs/weekly-$(date +%Y%m%d).log"
+exec > >(tee -a "$LOG") 2>&1
+
+echo "──────────────────────────────────────────────────────────────────"
+echo "[weekly] starting at $(date -Iseconds)"
+echo "──────────────────────────────────────────────────────────────────"
+
+DB=restaurant_professional_directory
+GEORGE_URL="${GEORGE_URL:-http://localhost:9850}"
+GEORGE_AUTH="${GEORGE_AUTH:-admin:DWSecure2024!}"
+STEVE_EMAIL="steveabramsdesigns@gmail.com"
+
+# Snapshot pre-state
+PRE_FACILITIES=$(psql -At -d $DB -c "SELECT COUNT(*) FROM facility")
+WEEK_AGO=$(date -v-7d -Iseconds)
+echo "[weekly] pre-pull: $PRE_FACILITIES facilities"
+
+# Force a fresh CSV download by removing the cache (>24h check is in script)
+rm -f data/la-eh-inventory.csv
+
+# Run inventory ingest; capture exit code
+INV_OK=true
+./node_modules/.bin/tsx scripts/ingest-inventory.ts || INV_OK=false
+
+POST_FACILITIES=$(psql -At -d $DB -c "SELECT COUNT(*) FROM facility")
+NEW_FACILITIES=$(psql -At -d $DB -c "SELECT COUNT(*) FROM facility WHERE first_seen_at > '$WEEK_AGO'")
+UPDATED=$(psql -At -d $DB -c "SELECT COUNT(*) FROM facility WHERE last_updated_at > '$WEEK_AGO' AND first_seen_at <= '$WEEK_AGO'")
+
+echo "[weekly] post-pull: $POST_FACILITIES facilities · $NEW_FACILITIES NEW this week · $UPDATED UPDATED"
+
+# Sync new/updated rows to Kamatera (full table dump+restore — ~30 MB, acceptable weekly)
+echo "[weekly] syncing to Kamatera…"
+SYNC_OK=true
+pg_dump -d $DB --data-only --no-owner --disable-triggers \
+  -t facility -t facility_enrichment -t ingest_run 2>&1 \
+  | ssh root@45.61.58.125 "sudo -u postgres psql -d $DB -v ON_ERROR_STOP=1 -q -c 'TRUNCATE facility CASCADE;' && sudo -u postgres psql -d $DB -v ON_ERROR_STOP=1 -q" \
+  || SYNC_OK=false
+
+# Background-enrich any new facilities (no wait — cuisine job is long-running)
+if [ "$NEW_FACILITIES" -gt 0 ] && [ "$INV_OK" = true ]; then
+  echo "[weekly] backgrounding cuisine enrichment for new facilities…"
+  nohup ./node_modules/.bin/tsx scripts/enrich-cuisine.ts > "logs/cuisine-after-weekly-$(date +%Y%m%d).log" 2>&1 &
+fi
+
+# Build the email body
+SUBJECT="LA County Eats — weekly ingest: $NEW_FACILITIES new, $UPDATED updated"
+WARN=""
+if [ "$INV_OK" = false ] || [ "$SYNC_OK" = false ]; then
+  SUBJECT="WARNING — $SUBJECT"
+  WARN="<p style='color:#b94a48;'><strong>WARNING:</strong> ingest=$INV_OK / kamatera-sync=$SYNC_OK</p>"
+fi
+
+BODY="<h2>LA County Eats — weekly ingest</h2>
+<p>$(date '+%A %B %-d, %Y at %H:%M %Z')</p>
+$WARN
+<table cellspacing='0' cellpadding='6' border='1' style='border-collapse:collapse;font-family:sans-serif;'>
+  <tr><th align='left'>Pre-pull facilities</th><td>$PRE_FACILITIES</td></tr>
+  <tr><th align='left'>Post-pull facilities</th><td>$POST_FACILITIES</td></tr>
+  <tr><th align='left'>NEW this week</th><td><strong>$NEW_FACILITIES</strong></td></tr>
+  <tr><th align='left'>UPDATED this week</th><td>$UPDATED</td></tr>
+</table>
+<p>Site: <a href='https://lacountyeats.com/'>lacountyeats.com</a></p>
+<p style='color:#888;font-size:12px;'>Logged at $LOG</p>"
+
+# Send via George (account=info per project_george_account_param.md)
+curl -s -X POST "${GEORGE_URL}/api/send?account=info" \
+  -H "Authorization: Basic $(printf '%s' "$GEORGE_AUTH" | base64)" \
+  -H "Content-Type: application/json" \
+  -d "$(jq -n --arg to "$STEVE_EMAIL" --arg subject "$SUBJECT" --arg body "$BODY" '{to:$to, subject:$subject, body:$body}')" \
+  > /dev/null && echo "[weekly] email sent to $STEVE_EMAIL" || echo "[weekly] email FAILED"
+
+echo "[weekly] DONE at $(date -Iseconds)"
diff --git a/src/api/server.ts b/src/api/server.ts
new file mode 100644
index 0000000..1d31fd7
--- /dev/null
+++ b/src/api/server.ts
@@ -0,0 +1,339 @@
+// MUST be the first import — sets PG env defaults before directory-core/db loads.
+import './_pg-defaults.ts';
+import 'dotenv/config';
+/**
+ * LA County Eats — single Express server (API + HTML routes).
+ * Port 9744 by default.
+ *
+ * Routes:
+ *   GET /                          home / list view (paginated, filterable)
+ *   GET /r/:slug                   restaurant detail
+ *   GET /map                       map view (all 37K pins)
+ *   GET /api/restaurants           JSON list (?city, ?cuisine, ?zip, ?q, ?limit, ?offset)
+ *   GET /api/restaurants/:slug     JSON detail
+ *   GET /healthz                   health check
+ */
+import express from 'express';
+import compression from 'compression';
+import helmet from 'helmet';
+import path from 'node:path';
+// 2026-05-04: migrated from inline `new Pool({...})` to directory-core/db.
+// Pool is lazily built on first use; PG config comes from env (defaults set
+// in _pg-defaults.ts above for backward compat).
+import { pool } from 'directory-core/db';
+
+const PORT = parseInt(process.env.PORT ?? '9744', 10);
+const HOST = process.env.HOST ?? '127.0.0.1';
+
+const app = express();
+
+app.use(helmet({
+  contentSecurityPolicy: {
+    directives: {
+      defaultSrc: ["'self'"],
+      styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://unpkg.com'],
+      fontSrc: ["'self'", 'https://fonts.gstatic.com'],
+      imgSrc: ["'self'", 'data:', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
+      scriptSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
+      connectSrc: ["'self'"],
+    },
+  },
+  crossOriginEmbedderPolicy: false,
+}));
+app.use(compression());
+app.set('view engine', 'ejs');
+app.set('views', path.resolve(__dirname, '..', 'web', 'views'));
+app.use('/static', express.static(path.resolve(__dirname, '..', '..', 'public')));
+// robots.txt at root (search engines won't read /static/robots.txt)
+app.get('/robots.txt', (_req, res) => {
+  res.type('text/plain');
+  res.set('Cache-Control', 'public, max-age=3600');
+  res.sendFile(path.resolve(__dirname, '..', '..', 'public', 'robots.txt'));
+});
+
+// Cheap UA-based scraper block at the app layer (defense-in-depth behind Cloudflare WAF).
+// Allows GET requests with empty/curl/wget/python/etc. UAs to /healthz only; everything else → 403.
+const SCRAPER_UA = /(scrapy|python-requests|python-urllib|Go-http-client|Java\/\d|libwww-perl|^curl\/|^Wget|^aiohttp|^node-fetch|^axios\/|HTTPClient\/|Postman|Insomnia)/i;
+const BLOCKED_AI_UA = /(GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|PerplexityBot|cohere-ai|Bytespider|Amazonbot|Applebot-Extended|FacebookBot|Meta-ExternalAgent|ImagesiftBot|OAI-SearchBot|Diffbot|Omgili|omgilibot|SemrushBot|AhrefsBot|MJ12bot|DotBot|BLEXBot|DataForSeoBot)/i;
+
+app.use((req, res, next) => {
+  // Endpoints that must remain reachable by any UA (search-engine crawlers,
+  // health checks, Cloudflare origin probes). Sitemap and robots.txt are the
+  // SEO contract — Googlebot fetches them with its own UA but we don't want
+  // CCBot here either; the BLOCKED_AI_UA filter still catches AI scrapers.
+  const PUBLIC_PATHS = req.path === '/healthz' || req.path === '/robots.txt' || req.path.startsWith('/sitemap');
+  const ua = req.get('user-agent') ?? '';
+  // Always block AI scrapers, even on /sitemap*.xml.
+  if (BLOCKED_AI_UA.test(ua)) {
+    res.status(403).type('text/plain').send('AI crawlers not permitted. See /robots.txt.');
+    return;
+  }
+  if (PUBLIC_PATHS) return next();
+  if (!ua || SCRAPER_UA.test(ua)) {
+    res.status(403).type('text/plain').send('Scraping not permitted. See /robots.txt.');
+    return;
+  }
+  next();
+});
+
+// ────────────────────────────────────────────────────────────────────────
+// Helpers
+// ────────────────────────────────────────────────────────────────────────
+type FacilityRow = {
+  facility_id: string; slug: string; name: string;
+  address: string | null; city: string | null; zip: string | null;
+  lat: number | null; lng: number | null;
+  facility_type: string | null; pe_description: string | null;
+};
+
+const LIST_QUERY = `
+  SELECT
+    f.facility_id, f.slug, f.name, f.address, f.city, f.zip,
+    f.lat::float AS lat, f.lng::float AS lng,
+    f.facility_type, f.pe_description,
+    f.source_type, f.opened_on::text AS opened_on, f.source_url,
+    e.cuisine, e.cuisine_confidence
+  FROM facility f
+  LEFT JOIN facility_enrichment e ON e.facility_id = f.facility_id
+`;
+
+// ────────────────────────────────────────────────────────────────────────
+// HTML routes
+// ────────────────────────────────────────────────────────────────────────
+app.get('/', async (req, res, next) => {
+  try {
+    const city = (req.query.city as string)?.toUpperCase().trim() || null;
+    const cuisine = (req.query.cuisine as string)?.trim() || null;
+    const q = (req.query.q as string)?.trim() || null;
+    const page = Math.max(1, parseInt(req.query.page as string ?? '1', 10) || 1);
+    const limit = 50;
+    const offset = (page - 1) * limit;
+
+    const where: string[] = ['f.is_active = true', 'f.facility_type = \'restaurant\''];
+    const params: any[] = [];
+    if (city)    { params.push(city);    where.push(`UPPER(f.city) = $${params.length}`); }
+    if (cuisine) { params.push(cuisine); where.push(`e.cuisine = $${params.length}`); }
+    if (q)       { params.push(`%${q}%`); where.push(`f.name ILIKE $${params.length}`); }
+
+    params.push(limit, offset);
+    const sql = `${LIST_QUERY} WHERE ${where.join(' AND ')} ORDER BY f.name LIMIT $${params.length-1} OFFSET $${params.length}`;
+    const { rows } = await pool.query<FacilityRow>(sql, params);
+
+    const countParams = params.slice(0, -2);
+    const { rows: cnt } = await pool.query<{ total: string }>(
+      `SELECT COUNT(*)::text AS total
+       FROM facility f
+       LEFT JOIN facility_enrichment e ON e.facility_id = f.facility_id
+       WHERE ${where.join(' AND ')}`,
+      countParams
+    );
+    const total = parseInt(cnt[0].total, 10);
+
+    const { rows: cities } = await pool.query<{ city: string; n: number }>(
+      `SELECT city, COUNT(*)::int AS n FROM facility WHERE city IS NOT NULL AND facility_type = 'restaurant' GROUP BY city ORDER BY n DESC LIMIT 25`
+    );
+
+    const { rows: cuisines } = await pool.query<{ cuisine: string; n: number }>(
+      `SELECT cuisine, COUNT(*)::int AS n FROM facility_enrichment
+       WHERE cuisine IS NOT NULL AND cuisine NOT IN ('Non-restaurant')
+       GROUP BY cuisine ORDER BY 2 DESC LIMIT 30`
+    );
+
+    // Top cuisines hero block — only shown when no filters applied (homepage)
+    const showHero = !city && !cuisine && !q && page === 1;
+    const heroCuisines = showHero ? cuisines.slice(0, 12) : [];
+
+    // Newest 6 LA restaurants — only on the unfiltered homepage. Press-sourced
+    // openings (source_type='press') float to the top by opened_on, then
+    // permitted facilities by first_seen_at. Filtered to City of LA so the
+    // section feels local; widening would just surface 6 random recent permits.
+    const newestRows = showHero ? (await pool.query<FacilityRow & { opened_on: string | null }>(
+      `${LIST_QUERY}
+       WHERE f.is_active = true
+         AND f.facility_type = 'restaurant'
+         AND UPPER(f.city) = 'LOS ANGELES'
+       ORDER BY COALESCE(f.opened_on, f.first_seen_at::date) DESC, f.first_seen_at DESC
+       LIMIT 6`
+    )).rows : [];
+
+    res.render('list', { rows, total, page, limit, city, cuisine, q, cities, cuisines, heroCuisines, showHero, newestRows });
+  } catch (e) { next(e); }
+});
+
+// ────────────────────────────────────────────────────────────────────────
+// City landing page — /c/<slug> e.g. /c/los-angeles
+// ────────────────────────────────────────────────────────────────────────
+app.get('/c/:slug', async (req, res, next) => {
+  try {
+    const slug = req.params.slug.toLowerCase();
+    const { rows: cityRow } = await pool.query<{ city: string; n: number }>(
+      `SELECT city, COUNT(*)::int AS n FROM facility
+       WHERE facility_type = 'restaurant'
+         AND lower(regexp_replace(city, '[^a-zA-Z0-9]+', '-', 'g')) = $1
+       GROUP BY city ORDER BY n DESC LIMIT 1`,
+      [slug]
+    );
+    if (cityRow.length === 0) { res.status(404).render('not-found'); return; }
+
+    const city = cityRow[0].city;
+    const total = cityRow[0].n;
+
+    // Cuisine breakdown
+    const { rows: cityCuisines } = await pool.query<{ cuisine: string; n: number }>(
+      `SELECT e.cuisine, COUNT(*)::int AS n
+       FROM facility f
+       JOIN facility_enrichment e ON e.facility_id = f.facility_id
+       WHERE f.facility_type = 'restaurant' AND UPPER(f.city) = UPPER($1)
+         AND e.cuisine IS NOT NULL AND e.cuisine NOT IN ('Non-restaurant')
+       GROUP BY e.cuisine ORDER BY 2 DESC LIMIT 20`,
+      [city]
+    );
+
+    // Top 50 by name (alphabetical for predictability)
+    const { rows: topRows } = await pool.query<FacilityRow>(
+      `${LIST_QUERY} WHERE f.facility_type = 'restaurant' AND UPPER(f.city) = UPPER($1) ORDER BY f.name LIMIT 50`,
+      [city]
+    );
+
+    res.render('city', { city, slug, total, cityCuisines, topRows });
+  } catch (e) { next(e); }
+});
+
+app.get('/r/:slug', async (req, res, next) => {
+  try {
+    const detailSql = `
+      SELECT
+        f.facility_id, f.slug, f.name, f.address, f.city, f.state, f.zip,
+        f.lat::float AS lat, f.lng::float AS lng,
+        f.facility_type, f.pe_code, f.pe_description, f.seat_tier,
+        f.owner_name, f.first_seen_at::text, f.last_updated_at::text,
+        e.cuisine, e.cuisine_confidence
+      FROM facility f
+      LEFT JOIN facility_enrichment e ON e.facility_id = f.facility_id
+      WHERE f.slug = $1 LIMIT 1`;
+    const { rows: r2 } = await pool.query(detailSql, [req.params.slug]);
+    if (r2.length === 0) { res.status(404).render('not-found'); return; }
+
+    const fac = r2[0];
+    res.render('detail', { fac });
+  } catch (e) { next(e); }
+});
+
+app.get('/map', async (_req, res, next) => {
+  try {
+    const { rows: stats } = await pool.query<{ n: string }>(
+      `SELECT COUNT(*)::text AS n FROM facility WHERE facility_type = 'restaurant' AND lat IS NOT NULL`
+    );
+    res.render('map', { total: parseInt(stats[0].n, 10) });
+  } catch (e) { next(e); }
+});
+
+// ────────────────────────────────────────────────────────────────────────
+// JSON API
+// ────────────────────────────────────────────────────────────────────────
+app.get('/api/restaurants', async (req, res, next) => {
+  try {
+    const limit = Math.min(500, parseInt(req.query.limit as string ?? '50', 10) || 50);
+    const offset = Math.max(0, Math.min(100000, parseInt(req.query.offset as string ?? '0', 10) || 0));
+    const where: string[] = ['f.is_active = true', 'f.facility_type = \'restaurant\''];
+    const params: any[] = [];
+    if (req.query.city) { params.push((req.query.city as string).toUpperCase()); where.push(`UPPER(f.city) = $${params.length}`); }
+    if (req.query.zip)  { params.push(req.query.zip); where.push(`f.zip = $${params.length}`); }
+    if (req.query.q)    { params.push(`%${req.query.q}%`); where.push(`f.name ILIKE $${params.length}`); }
+    params.push(limit, offset);
+    const sql = `${LIST_QUERY} WHERE ${where.join(' AND ')} ORDER BY f.name LIMIT $${params.length-1} OFFSET $${params.length}`;
+    const { rows } = await pool.query(sql, params);
+    res.json({ count: rows.length, results: rows });
+  } catch (e) { next(e); }
+});
+
+app.get('/api/restaurants/:slug', async (req, res, next) => {
+  try {
+    const { rows } = await pool.query(`${LIST_QUERY} WHERE f.is_active = true AND f.slug = $1 LIMIT 1`, [req.params.slug]);
+    if (rows.length === 0) { res.status(404).json({ error: 'not_found' }); return; }
+    res.json(rows[0]);
+  } catch (e) { next(e); }
+});
+
+// Bulk pins endpoint for the map view (cached aggressively)
+app.get('/api/pins', async (_req, res, next) => {
+  try {
+    const { rows } = await pool.query(
+      `SELECT facility_id, slug, name, lat::float AS lat, lng::float AS lng
+       FROM facility
+       WHERE is_active = true AND facility_type = 'restaurant' AND lat IS NOT NULL AND lng IS NOT NULL`
+    );
+    res.set('Cache-Control', 'public, max-age=3600');
+    res.json({ count: rows.length, pins: rows });
+  } catch (e) { next(e); }
+});
+
+app.get('/healthz', (_req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
+
+// ────────────────────────────────────────────────────────────────────────
+// Sitemap — split into index + per-city chunks so we stay under Google's
+// 50K-URL / 50MB-uncompressed limit per file. ~37K facilities → 1 file.
+// ────────────────────────────────────────────────────────────────────────
+app.get('/sitemap.xml', async (_req, res, next) => {
+  try {
+    const { rows: cities } = await pool.query<{ city: string }>(
+      `SELECT DISTINCT UPPER(city) AS city FROM facility WHERE facility_type = 'restaurant' AND city IS NOT NULL ORDER BY 1`
+    );
+    const base = `https://lacountyeats.com`;
+    const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <sitemap><loc>${base}/sitemap-restaurants.xml</loc></sitemap>
+${cities.map(c => `  <sitemap><loc>${base}/sitemap-city-${encodeURIComponent(c.city.toLowerCase().replace(/[^a-z0-9]+/g, '-'))}.xml</loc></sitemap>`).join('\n')}
+</sitemapindex>`;
+    res.type('application/xml').set('Cache-Control', 'public, max-age=86400').send(xml);
+  } catch (e) { next(e); }
+});
+
+// Per-city sitemap — slug → city name (case-insensitive)
+app.get(/^\/sitemap-city-([a-z0-9-]+)\.xml$/, async (req, res, next) => {
+  try {
+    const slug = (req.params as any)[0] as string;
+    // Resolve slug → city name via reverse-slugify (the slugify pattern in
+    // ingest is `lower(city).replace(/[^a-z0-9]+/g, '-')`; we just match it)
+    const { rows } = await pool.query<{ slug: string; last_updated_at: string }>(
+      `SELECT slug, last_updated_at::text FROM facility
+       WHERE facility_type = 'restaurant'
+         AND lower(regexp_replace(city, '[^a-zA-Z0-9]+', '-', 'g')) = $1
+       ORDER BY name LIMIT 50000`,
+      [slug]
+    );
+    if (rows.length === 0) { res.status(404).type('text/plain').send('No such city'); return; }
+    const base = `https://lacountyeats.com`;
+    const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url><loc>${base}/c/${slug}</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
+${rows.map(r => `  <url><loc>${base}/r/${r.slug}</loc><lastmod>${(r.last_updated_at ?? new Date().toISOString()).slice(0, 10)}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`).join('\n')}
+</urlset>`;
+    res.type('application/xml').set('Cache-Control', 'public, max-age=86400').send(xml);
+  } catch (e) { next(e); }
+});
+
+app.get('/sitemap-restaurants.xml', async (_req, res, next) => {
+  try {
+    const { rows } = await pool.query<{ slug: string; last_updated_at: string }>(
+      `SELECT slug, last_updated_at::text FROM facility WHERE facility_type = 'restaurant' ORDER BY name LIMIT 50000`
+    );
+    const base = `https://lacountyeats.com`;
+    const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url><loc>${base}/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
+  <url><loc>${base}/map</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
+${rows.map(r => `  <url><loc>${base}/r/${r.slug}</loc><lastmod>${(r.last_updated_at ?? new Date().toISOString()).slice(0, 10)}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`).join('\n')}
+</urlset>`;
+    res.type('application/xml').set('Cache-Control', 'public, max-age=86400').send(xml);
+  } catch (e) { next(e); }
+});
+
+app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+  console.error('[server] error:', err);
+  res.status(500).json({ error: 'internal_server_error', message: err.message });
+});
+
+app.listen(PORT, HOST, () => {
+  console.log(`[lacountyeats] listening on http://${HOST}:${PORT}`);
+});
diff --git a/src/web/views/city.ejs b/src/web/views/city.ejs
new file mode 100644
index 0000000..55e05ce
--- /dev/null
+++ b/src/web/views/city.ejs
@@ -0,0 +1,49 @@
+<%- include('partials/head', { title: city + ' restaurants', description: 'All ' + total.toLocaleString() + ' restaurants in ' + city + '. Address, cuisine, neighborhood.' }) %>
+
+<main class="container">
+  <p class="crumbs"><a href="/">All restaurants</a> · <%= city %></p>
+
+  <header class="hero">
+    <h1><%= city %>.</h1>
+    <p class="lede"><%= total.toLocaleString() %> restaurants.</p>
+  </header>
+
+  <% if (cityCuisines.length > 0) { %>
+    <section class="cuisine-hero">
+      <h2>Cuisines in <%= city %></h2>
+      <ul>
+        <% cityCuisines.slice(0, 12).forEach(c => { %>
+          <li><a href="/?city=<%= encodeURIComponent(city) %>&cuisine=<%= encodeURIComponent(c.cuisine) %>">
+            <span class="ch-name"><%= c.cuisine %></span>
+            <span class="ch-n"><%= c.n.toLocaleString() %></span>
+          </a></li>
+        <% }); %>
+      </ul>
+    </section>
+  <% } %>
+
+  <section style="margin-top:2rem;">
+    <h2 style="font-family:'Playfair Display',Georgia,serif;font-size:1.5rem;">First 50 alphabetically</h2>
+    <p class="muted" style="margin-top:-0.25rem;font-size:.85rem;">
+      <a href="/?city=<%= encodeURIComponent(city) %>">See all <%= total.toLocaleString() %> →</a>
+    </p>
+    <ol class="rest-list">
+      <% topRows.forEach(r => { %>
+        <li>
+          <div class="group rest-card" style="display:grid;grid-template-columns:1fr auto;gap:1.5rem;align-items:center;padding:1rem 0;">
+            <div class="rest-main">
+              <a href="/r/<%= r.slug %>"><h2 style="font-size:1.15rem;margin:0;"><%= r.name %></h2></a>
+              <p class="addr"><%= r.address %></p>
+              <% if (r.cuisine) { %><p class="cuisine" style="display:inline-block;"><%= r.cuisine %></p><% } %>
+            </div>
+            <div class="rest-meta">
+              <% if (r.cuisine) { %><span class="cuisine"><%= r.cuisine %></span><% } %>
+            </div>
+          </div>
+        </li>
+      <% }); %>
+    </ol>
+  </section>
+</main>
+
+<%- include('partials/foot') %>
diff --git a/src/web/views/detail.ejs b/src/web/views/detail.ejs
new file mode 100644
index 0000000..a745b84
--- /dev/null
+++ b/src/web/views/detail.ejs
@@ -0,0 +1,45 @@
+<%- include('partials/head', { title: fac.name, description: fac.name + ' — ' + (fac.address || '') + ', ' + (fac.city || '') + '.' }) %>
+
+<main class="container detail">
+  <p class="crumbs"><a href="/">All restaurants</a> · <%= fac.city || 'LA County' %></p>
+
+  <header class="detail-head">
+    <h1><%= fac.name %></h1>
+    <p class="addr"><%= fac.address %><%= fac.city ? ', ' + fac.city : '' %><%= fac.state ? ', ' + fac.state : '' %><%= fac.zip ? ' ' + fac.zip : '' %></p>
+    <% if (fac.cuisine) { %>
+      <p class="cuisine"><%= fac.cuisine %></p>
+    <% } %>
+    <% if (fac.pe_description) { %>
+      <p class="pe"><%= fac.pe_description %></p>
+    <% } %>
+  </header>
+
+  <% if (fac.lat && fac.lng) { %>
+    <section class="map-block">
+      <div id="mini-map" data-lat="<%= fac.lat %>" data-lng="<%= fac.lng %>" data-name="<%= fac.name %>"></div>
+    </section>
+  <% } %>
+
+  <section class="meta">
+    <dl>
+      <dt>Permit ID</dt><dd><%= fac.facility_id %></dd>
+      <% if (fac.owner_name) { %><dt>Operator</dt><dd><%= fac.owner_name %></dd><% } %>
+      <% if (fac.seat_tier) { %><dt>Seat capacity</dt><dd><%= fac.seat_tier %></dd><% } %>
+      <dt>First seen</dt><dd><%= fac.first_seen_at?.slice(0, 10) %></dd>
+    </dl>
+  </section>
+</main>
+
+<% if (fac.lat && fac.lng) { %>
+  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
+  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+  <script>
+    const el = document.getElementById('mini-map');
+    const lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
+    const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OSM', maxZoom: 19 }).addTo(map);
+    L.marker([lat, lng]).addTo(map).bindPopup(el.dataset.name).openPopup();
+  </script>
+<% } %>
+
+<%- include('partials/foot') %>
diff --git a/src/web/views/list.ejs b/src/web/views/list.ejs
new file mode 100644
index 0000000..0db61f7
--- /dev/null
+++ b/src/web/views/list.ejs
@@ -0,0 +1,104 @@
+<%- include('partials/head', { title: 'All restaurants' }) %>
+
+<main class="container">
+  <section class="hero">
+    <h1>Every restaurant in LA County.</h1>
+    <p class="lede">
+      <%= total.toLocaleString() %> spots match your filters · address, cuisine, map.
+    </p>
+
+    <form method="get" class="filters" action="/">
+      <input type="search" name="q" placeholder="Search by name…" value="<%= q || '' %>" autocomplete="off">
+      <select name="city">
+        <option value="">All cities</option>
+        <% cities.forEach(c => { %>
+          <option value="<%= c.city %>" <%= city === c.city ? 'selected' : '' %>><%= c.city %> (<%= c.n.toLocaleString() %>)</option>
+        <% }); %>
+      </select>
+      <select name="cuisine">
+        <option value="">Any cuisine</option>
+        <% cuisines.forEach(c => { %>
+          <option value="<%= c.cuisine %>" <%= cuisine === c.cuisine ? 'selected' : '' %>><%= c.cuisine %> (<%= c.n.toLocaleString() %>)</option>
+        <% }); %>
+      </select>
+      <button type="submit">Filter</button>
+      <% if (city || cuisine || q) { %>
+        <a href="/" class="clear">Clear</a>
+      <% } %>
+    </form>
+  </section>
+
+  <% if (showHero && typeof newestRows !== 'undefined' && newestRows.length > 0) { %>
+    <section class="newest-hero">
+      <h2>Newest in Los Angeles</h2>
+      <p class="muted" style="margin-top:-0.4rem;font-size:.85rem;">Just opened — sourced from Eater LA, Hoodline, and DPH new permits.</p>
+      <ul>
+        <% newestRows.forEach(r => { %>
+          <li>
+            <a href="/r/<%= r.slug %>" class="newest-card">
+              <% if (r.source_type === 'press') { %><span class="badge badge-new">NEW</span><% } %>
+              <h3><%= r.name %></h3>
+              <p class="addr"><%= r.address %><%= r.city ? ', ' + r.city : '' %></p>
+              <% if (r.cuisine) { %><p class="cuisine"><%= r.cuisine %></p><% } %>
+              <% if (r.opened_on) { %><p class="opened">Opened <%= new Date(r.opened_on + 'T00:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) %></p><% } %>
+            </a>
+          </li>
+        <% }); %>
+      </ul>
+    </section>
+  <% } %>
+
+  <% if (showHero && heroCuisines.length > 0) { %>
+    <section class="cuisine-hero">
+      <h2>Browse by cuisine</h2>
+      <ul>
+        <% heroCuisines.forEach(c => { %>
+          <li><a href="/?cuisine=<%= encodeURIComponent(c.cuisine) %>">
+            <span class="ch-name"><%= c.cuisine %></span>
+            <span class="ch-n"><%= c.n.toLocaleString() %></span>
+          </a></li>
+        <% }); %>
+      </ul>
+    </section>
+  <% } %>
+
+  <ol class="rest-list">
+    <% rows.forEach(r => { %>
+      <li>
+        <a href="/r/<%= r.slug %>" class="rest-card">
+          <div class="rest-main">
+            <h2><%= r.name %></h2>
+            <p class="addr"><%= r.address %><%= r.city ? ', ' + r.city : '' %><%= r.zip ? ' ' + r.zip : '' %></p>
+            <% if (r.cuisine) { %>
+              <p class="cuisine"><%= r.cuisine %></p>
+            <% } %>
+            <% if (r.pe_description) { %>
+              <p class="pe"><%= r.pe_description %></p>
+            <% } %>
+          </div>
+        </a>
+      </li>
+    <% }); %>
+  </ol>
+
+  <% if (rows.length === 0) { %>
+    <p class="empty">No restaurants matched. Try a different filter.</p>
+  <% } %>
+
+  <% const totalPages = Math.ceil(total / limit); %>
+  <% if (totalPages > 1) { %>
+    <nav class="pager">
+      <% if (page > 1) { %>
+        <% const prev = new URLSearchParams({ ...(q?{q}:{}), ...(city?{city}:{}), ...(cuisine?{cuisine}:{}), page: page - 1 }); %>
+        <a href="/?<%= prev %>">← Prev</a>
+      <% } %>
+      <span>Page <%= page %> of <%= totalPages.toLocaleString() %></span>
+      <% if (page < totalPages) { %>
+        <% const next = new URLSearchParams({ ...(q?{q}:{}), ...(city?{city}:{}), ...(cuisine?{cuisine}:{}), page: page + 1 }); %>
+        <a href="/?<%= next %>">Next →</a>
+      <% } %>
+    </nav>
+  <% } %>
+</main>
+
+<%- include('partials/foot') %>
diff --git a/src/web/views/partials/foot.ejs b/src/web/views/partials/foot.ejs
new file mode 100644
index 0000000..0657547
--- /dev/null
+++ b/src/web/views/partials/foot.ejs
@@ -0,0 +1,11 @@
+<footer class="site-footer">
+  <p>
+    <strong>LA County Eats</strong> — directory of restaurants across Los Angeles County. Search by city, neighborhood, and cuisine.
+  </p>
+  <p class="muted">
+    Coverage gaps: Pasadena, Long Beach, and Vernon are not yet included.
+  </p>
+  <p class="muted small">© <%= new Date().getFullYear() %> · Built in LA</p>
+</footer>
+</body>
+</html>
diff --git a/src/web/views/partials/head.ejs b/src/web/views/partials/head.ejs
new file mode 100644
index 0000000..d78de85
--- /dev/null
+++ b/src/web/views/partials/head.ejs
@@ -0,0 +1,37 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <script>
+    (function(){try{var t=localStorage.getItem('theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
+  </script>
+  <title><%= typeof title !== 'undefined' ? title + ' — LA County Eats' : 'LA County Eats — every restaurant in LA County' %></title>
+  <meta name="description" content="<%= typeof description !== 'undefined' ? description : 'Searchable directory of restaurants across Los Angeles County. ~37,000 spots. Address, neighborhood, cuisine, map.' %>">
+  <meta name="theme-color" content="#fdf9ee" media="(prefers-color-scheme: light)">
+  <meta name="theme-color" content="#1a1814" media="(prefers-color-scheme: dark)">
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="/static/css/site.css">
+</head>
+<body>
+<header class="site-header">
+  <a href="/" class="brand">LA County Eats</a>
+  <nav>
+    <a href="/">List</a>
+    <a href="/map">Map</a>
+    <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false" title="Toggle dark / light">
+      <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
+      <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
+    </button>
+  </nav>
+</header>
+<script>
+  (function(){
+    var btn=document.getElementById('theme-toggle');if(!btn)return;
+    function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}
+    sync();
+    btn.addEventListener('click',function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var nxt=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',nxt);try{localStorage.setItem('theme',nxt);}catch(e){}sync();});
+  })();
+</script>

← 79f5c90 add canonical .gitignore per standing rule  ·  back to Restaurant Directory  ·  rel=alternate JSON + HTTP Link header on / and /c/:slug 963d23a →