← 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
A scripts/weekly-ingest.shA src/api/server.tsA src/web/views/city.ejsA src/web/views/detail.ejsA src/web/views/list.ejsA src/web/views/partials/foot.ejsA src/web/views/partials/head.ejs
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 →