[object Object]

← back to La Socrata Ingester

Viewer: sortable+movable-column table + permit_lead_score ranking (default sort)

f47742c9fc187edb1ef1eaff421e73daf3f12b1b · 2026-08-11 08:32:19 -0700 · steve

Every column server-sortable (whitelisted sort=<col>&dir); drag-to-reorder columns
persisted to localStorage; lead_score (0-100) ranking as default sort. Row-density
slider, faceted filter rail with live counts, detail modal w/ parcel enrichment +
Google Maps + raw JSON. On port 9814.

Files touched

Diff

commit f47742c9fc187edb1ef1eaff421e73daf3f12b1b
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 11 08:32:19 2026 -0700

    Viewer: sortable+movable-column table + permit_lead_score ranking (default sort)
    
    Every column server-sortable (whitelisted sort=<col>&dir); drag-to-reorder columns
    persisted to localStorage; lead_score (0-100) ranking as default sort. Row-density
    slider, faceted filter rail with live counts, detail modal w/ parcel enrichment +
    Google Maps + raw JSON. On port 9814.
---
 db/lead_score.sql        |  63 ++++
 package-lock.json        | 851 +++++++++++++++++++++++++++++++++++++++++++++++
 package.json             |   1 +
 viewer/public/index.html | 634 +++++++++++------------------------
 4 files changed, 1118 insertions(+), 431 deletions(-)

diff --git a/db/lead_score.sql b/db/lead_score.sql
new file mode 100644
index 0000000..c2443ba
--- /dev/null
+++ b/db/lead_score.sql
@@ -0,0 +1,63 @@
+-- permit_lead_score(): 0–100 lead-quality ranking for a building permit.
+-- One source of truth shared by the viewer API and the CSV exports.
+-- Weighting: recency (30) + project value (28) + work type (18) + status (14)
+--            + building type (10)  =  100 max.  STABLE (reads now()).
+CREATE OR REPLACE FUNCTION permit_lead_score(
+  p_issue_date  timestamptz,
+  p_valuation   numeric,
+  p_permit_type text,
+  p_sub_type    text,
+  p_status      text
+) RETURNS integer LANGUAGE sql STABLE AS $$
+  SELECT (
+    -- recency (0–30): fresher = hotter
+    CASE
+      WHEN p_issue_date IS NULL THEN 0
+      WHEN p_issue_date >= now() - interval '30 days'  THEN 30
+      WHEN p_issue_date >= now() - interval '90 days'  THEN 24
+      WHEN p_issue_date >= now() - interval '180 days' THEN 16
+      WHEN p_issue_date >= now() - interval '365 days' THEN 8
+      WHEN p_issue_date >= now() - interval '730 days' THEN 3
+      ELSE 0
+    END
+    -- project value (0–28): bigger job = bigger opportunity
+    + CASE
+      WHEN p_valuation IS NULL      THEN 0
+      WHEN p_valuation >= 5000000   THEN 28
+      WHEN p_valuation >= 2000000   THEN 24
+      WHEN p_valuation >= 1000000   THEN 20
+      WHEN p_valuation >= 500000    THEN 15
+      WHEN p_valuation >= 250000    THEN 11
+      WHEN p_valuation >= 100000    THEN 8
+      WHEN p_valuation >= 50000     THEN 5
+      WHEN p_valuation >= 10000     THEN 2
+      ELSE 0
+    END
+    -- work type (0–18): ground-up + additions are the premium jobs
+    + CASE p_permit_type
+      WHEN 'Bldg-New'          THEN 18
+      WHEN 'Bldg-Addition'     THEN 15
+      WHEN 'Swimming-Pool/Spa' THEN 12
+      WHEN 'Bldg-Alter/Repair' THEN 9
+      WHEN 'Grading'           THEN 6
+      WHEN 'Bldg-Demolition'   THEN 6
+      ELSE 3
+    END
+    -- status (0–14): active/issued work outranks finaled/expired
+    + CASE p_status
+      WHEN 'Issued'           THEN 14
+      WHEN 'CofO in Progress' THEN 9
+      WHEN 'CofO Issued'      THEN 7
+      WHEN 'Permit Finaled'   THEN 5
+      WHEN 'Permit Expired'   THEN 0
+      ELSE 4
+    END
+    -- building type (0–10)
+    + CASE p_sub_type
+      WHEN '1 or 2 Family Dwelling' THEN 10
+      WHEN 'Commercial'             THEN 9
+      WHEN 'Apartment'              THEN 8
+      ELSE 4
+    END
+  );
+$$;
diff --git a/package-lock.json b/package-lock.json
index a3d82dd..2a36d51 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,588 @@
       "version": "0.1.0",
       "license": "UNLICENSED",
       "dependencies": {
+        "express": "^5.2.1",
         "pg": "^8.13.1"
       },
       "bin": {
         "la-ingest": "src/cli.js"
       }
     },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+      "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/pg": {
       "version": "8.23.0",
       "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
@@ -143,6 +719,208 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/split2": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -152,6 +930,79 @@
         "node": ">= 10.x"
       }
     },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
     "node_modules/xtend": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index f427243..15faa5f 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
     "list": "node src/cli.js --list"
   },
   "dependencies": {
+    "express": "^5.2.1",
     "pg": "^8.13.1"
   },
   "author": "steve@designerwallcoverings.com",
diff --git a/viewer/public/index.html b/viewer/public/index.html
index d242a23..061dcce 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -6,476 +6,248 @@
 <title>LA Building Permits</title>
 <style>
   :root{
-    --bg:#f4f5f7; --panel:#ffffff; --ink:#1b1f24; --ink-soft:#5b636d; --ink-faint:#8a919b;
-    --line:#e2e5ea; --line-strong:#d3d8df; --teal:#0d9488; --teal-ink:#0b7d72; --teal-bg:#e6f6f4;
-    --amber:#b45309; --shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.04);
-    --shadow-lg:0 12px 40px rgba(16,24,40,.22);
-    --r:8px; --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-    --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+    --bg:#f6f7f9; --panel:#fff; --ink:#1a1f24; --muted:#6b7480; --line:#e3e7ec;
+    --teal:#0f766e; --teal-soft:#d7efec; --accent:#0e7490; --rowh:34px; --pad:6px 10px;
   }
   *{box-sizing:border-box}
-  html,body{margin:0;height:100%}
-  body{background:var(--bg);color:var(--ink);font:14px/1.45 var(--font);-webkit-font-smoothing:antialiased}
-  button{font:inherit;cursor:pointer;color:inherit}
-  input,select{font:inherit}
-
-  /* ---------- layout ---------- */
-  .app{display:grid;grid-template-columns:280px 1fr;grid-template-rows:auto 1fr;height:100vh}
-  header.top{grid-column:1/3;background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;z-index:5}
-  aside.rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:6px 0 40px}
-  main.results{overflow-y:auto;padding:16px}
-
-  /* ---------- top bar ---------- */
-  .brand{display:flex;align-items:baseline;gap:8px;min-width:210px}
-  .brand b{font-size:15px;letter-spacing:-.2px}
-  .brand .tag{font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.6px}
-  .search{position:relative;flex:1;min-width:220px;max-width:520px}
-  .search input{width:100%;padding:9px 12px 9px 34px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;outline:none}
-  .search input:focus{border-color:var(--teal);box-shadow:0 0 0 3px var(--teal-bg)}
-  .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--ink-faint)}
-  .count{font-size:13px;color:var(--ink-soft);white-space:nowrap}
-  .count b{color:var(--ink);font-variant-numeric:tabular-nums}
-  .ctrl{display:flex;align-items:center;gap:6px;color:var(--ink-soft);font-size:12px}
-  .ctrl select{padding:7px 8px;border:1px solid var(--line-strong);border-radius:6px;background:#fff;outline:none}
-  .ctrl select:focus{border-color:var(--teal)}
-  .dens{display:flex;align-items:center;gap:8px}
-  .dens input[type=range]{width:104px;accent-color:var(--teal)}
-
-  /* ---------- active filter chips ---------- */
-  .chips{grid-column:1/3;display:flex;gap:8px;flex-wrap:wrap;padding:8px 16px 0;background:var(--bg)}
-  .chips:empty{display:none}
-  .chip{display:inline-flex;align-items:center;gap:6px;background:var(--teal-bg);color:var(--teal-ink);border:1px solid #bfe8e2;border-radius:999px;padding:3px 6px 3px 10px;font-size:12px;font-weight:600}
-  .chip button{background:none;border:none;color:var(--teal-ink);line-height:1;font-size:14px;padding:0 2px;opacity:.7}
-  .chip button:hover{opacity:1}
-  .chip .clr-all{color:var(--ink-soft);font-weight:600;text-decoration:underline;background:none;border:none;font-size:12px}
-
-  /* ---------- rail facets ---------- */
-  .rail-hd{padding:12px 16px 6px;font-size:11px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:var(--ink-faint)}
-  .minirow{padding:0 12px 6px;display:flex;gap:8px}
-  .minirow label{flex:1;display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.4px}
-  .minirow input{padding:6px 8px;border:1px solid var(--line-strong);border-radius:6px;outline:none;width:100%}
-  .minirow input:focus{border-color:var(--teal)}
-  .facet{border-top:1px solid var(--line)}
-  .facet:first-of-type{border-top:none}
-  .facet>.fhd{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;background:none;border:none;text-align:left}
-  .facet>.fhd:hover{background:#fafbfc}
-  .fhd .ttl{font-weight:600;font-size:13px;display:flex;align-items:center;gap:7px}
-  .fhd .caret{color:var(--ink-faint);transition:transform .15s;font-size:10px}
-  .facet.collapsed .caret{transform:rotate(-90deg)}
-  .facet.collapsed .fbody{display:none}
-  .fhd .seldot{width:6px;height:6px;border-radius:50%;background:var(--teal);display:none}
-  .facet.has-sel .seldot{display:inline-block}
-  .fbody{max-height:260px;overflow-y:auto;padding:2px 8px 8px}
-  .fv{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:5px 8px;border:none;background:none;border-radius:6px;text-align:left;color:var(--ink-soft)}
-  .fv:hover{background:var(--teal-bg);color:var(--teal-ink)}
-  .fv.active{background:var(--teal);color:#fff}
-  .fv.active .fc{color:#fff;background:rgba(255,255,255,.22)}
-  .fv .fname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
-  .fv .fc{font-size:11px;color:var(--ink-faint);background:#eef0f3;border-radius:999px;padding:1px 7px;font-variant-numeric:tabular-nums;flex:none}
-
-  /* ---------- cards ---------- */
-  .grid{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:12px}
-  .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:13px 14px;display:flex;flex-direction:column;gap:9px;box-shadow:var(--shadow);cursor:pointer;transition:border-color .12s,box-shadow .12s;text-align:left;width:100%}
-  .card:hover{border-color:var(--teal);box-shadow:0 2px 8px rgba(13,148,136,.14)}
-  .card .addr{font-weight:700;font-size:14.5px;letter-spacing:-.2px;line-height:1.25}
-  .card .badges{display:flex;flex-wrap:wrap;gap:5px}
-  .badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:5px;background:#eef1f4;color:#485058;white-space:nowrap}
-  .badge.wt{background:#eaf1fb;color:#2b5aa0}
-  .badge.bt{background:#f1eefc;color:#5b45a6}
-  .val{font-size:19px;font-weight:700;letter-spacing:-.4px;font-variant-numeric:tabular-nums}
-  .val.zero{color:var(--ink-faint);font-weight:600;font-size:14px}
-  .metarow{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;color:var(--ink-soft)}
-  .pill{font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;border:1px solid var(--line-strong);background:#fff;white-space:nowrap}
-  .pill.ok{background:#eafaf1;color:#1a7f47;border-color:#bce9cf}
-  .pill.warn{background:#fef6e7;color:#a16207;border-color:#f5e0a8}
-  .pill.mut{background:#f3f4f6;color:#6b7280}
-  .subrow{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink-soft);border-top:1px dashed var(--line);padding-top:8px}
-  .subrow span b{color:var(--ink);font-weight:600}
-  .subrow .parcel{color:var(--teal-ink)}
-  .era-tag{font-size:10.5px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.5px}
-
-  /* ---------- load more / states ---------- */
-  .foot{display:flex;justify-content:center;padding:22px 0 8px}
-  .loadmore{padding:10px 26px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;font-weight:600;color:var(--ink)}
-  .loadmore:hover:not(:disabled){border-color:var(--teal);color:var(--teal-ink)}
-  .loadmore:disabled{opacity:.5;cursor:default}
-  .empty,.loading{text-align:center;color:var(--ink-faint);padding:60px 20px;font-size:14px}
-  .skel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);height:150px;box-shadow:var(--shadow);position:relative;overflow:hidden}
-  .skel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,0,0,.035),transparent);animation:sh 1.1s infinite}
-  @keyframes sh{100%{transform:translateX(100%)}}
-
-  /* ---------- modal ---------- */
-  .scrim{position:fixed;inset:0;background:rgba(16,24,40,.34);display:none;z-index:40}
-  .scrim.open{display:block}
-  .modal{position:fixed;top:80px;left:50%;transform:translateX(-50%);width:min(680px,94vw);max-height:82vh;background:var(--panel);border:1px solid var(--line-strong);border-radius:12px;box-shadow:var(--shadow-lg);display:none;flex-direction:column;z-index:41;overflow:hidden}
-  .modal.open{display:flex}
-  .m-hd{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);cursor:move;user-select:none;background:linear-gradient(#fff,#fcfdfe)}
-  .m-hd .h-txt{flex:1;min-width:0}
-  .m-hd .h-addr{font-weight:700;font-size:16px;letter-spacing:-.2px}
-  .m-hd .h-sub{font-size:12px;color:var(--ink-soft);margin-top:2px}
-  .m-hd .x{background:none;border:none;font-size:22px;line-height:1;color:var(--ink-faint);padding:0 4px}
-  .m-hd .x:hover{color:var(--ink)}
-  .m-bd{padding:14px 16px;overflow-y:auto}
-  .m-val{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:14px}
-  .m-val .big{font-size:26px;font-weight:800;letter-spacing:-.6px;font-variant-numeric:tabular-nums}
-  .kv{display:grid;grid-template-columns:150px 1fr;gap:2px 14px;font-size:13px}
-  .kv dt{color:var(--ink-faint);padding:6px 0;border-top:1px solid var(--line)}
-  .kv dd{margin:0;padding:6px 0;border-top:1px solid var(--line);color:var(--ink);word-break:break-word}
-  .kv dd a{color:var(--teal-ink);text-decoration:none;font-weight:600}
-  .kv dd a:hover{text-decoration:underline}
-  .kv .flt{cursor:pointer;color:var(--teal-ink);text-decoration:underline dotted;font-weight:600}
-  .sect{margin-top:16px;font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-faint)}
-  .desc{margin-top:8px;font-size:13px;color:var(--ink-soft);background:#f8f9fb;border:1px solid var(--line);border-radius:8px;padding:10px 12px}
-  .m-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}
-  .m-btn{padding:8px 14px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font-weight:600;font-size:13px;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:6px}
-  .m-btn:hover{border-color:var(--teal);color:var(--teal-ink)}
-  .m-btn.primary{background:var(--teal);border-color:var(--teal);color:#fff}
-  .m-btn.primary:hover{background:var(--teal-ink);color:#fff}
-  pre.raw{display:none;margin-top:10px;background:#0f172a;color:#cbd5e1;border-radius:8px;padding:12px;font:12px/1.5 var(--mono);overflow:auto;max-height:320px}
-  pre.raw.open{display:block}
-  .rz{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;color:var(--ink-faint)}
-
-  @media(max-width:820px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}
-    aside.rail{max-height:230px;border-right:none;border-bottom:1px solid var(--line)}}
+  body{margin:0;font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    color:var(--ink);background:var(--bg);height:100vh;overflow:hidden}
+  .app{display:grid;grid-template-columns:250px 1fr;grid-template-rows:auto 1fr;height:100vh}
+  header{grid-column:1/3;display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--panel);
+    border-bottom:1px solid var(--line);flex-wrap:wrap}
+  header h1{font-size:15px;margin:0;font-weight:650;letter-spacing:-.2px}
+  header h1 b{color:var(--teal)}
+  .count{color:var(--muted);font-size:12px}
+  .grow{flex:1}
+  input,select{font:inherit;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
+  input#q{width:230px}
+  .ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
+  aside{background:var(--panel);border-right:1px solid var(--line);overflow:auto;padding:8px 0}
+  .facet{border-bottom:1px solid var(--line)}
+  .facet h3{margin:0;padding:9px 14px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
+    cursor:pointer;display:flex;justify-content:space-between;user-select:none}
+  .facet h3::after{content:"▾";font-size:9px;opacity:.5}
+  .facet.collapsed h3::after{content:"▸"}
+  .facet ul{list-style:none;margin:0;padding:0 0 6px;max-height:230px;overflow:auto}
+  .facet.collapsed ul{display:none}
+  .facet li{display:flex;justify-content:space-between;gap:8px;padding:4px 14px;cursor:pointer;font-size:12px}
+  .facet li:hover{background:#f0f3f5}
+  .facet li.active{background:var(--teal-soft);color:var(--teal);font-weight:600}
+  .facet li .c{color:var(--muted);font-variant-numeric:tabular-nums;font-size:11px}
+  .facet li.active .c{color:var(--teal)}
+  main{overflow:auto;position:relative}
+  table{border-collapse:separate;border-spacing:0;width:100%;background:var(--panel)}
+  thead th{position:sticky;top:0;z-index:2;background:#eef1f4;border-bottom:1px solid var(--line);
+    text-align:left;padding:var(--pad);font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#495057;
+    cursor:pointer;white-space:nowrap;user-select:none}
+  thead th:hover{background:#e5e9ee}
+  thead th.drag{opacity:.4}
+  thead th .arrow{color:var(--teal);margin-left:3px}
+  thead th .grip{color:#b7bfc7;margin-right:5px;cursor:grab}
+  tbody td{padding:var(--pad);border-bottom:1px solid var(--line);white-space:nowrap;height:var(--rowh);
+    max-width:340px;overflow:hidden;text-overflow:ellipsis}
+  tbody tr:hover{background:#f0f9f8;cursor:pointer}
+  .num{text-align:right;font-variant-numeric:tabular-nums}
+  .addr{font-weight:600}
+  .badge{display:inline-block;padding:1px 7px;border-radius:20px;font-size:11px;font-weight:600;background:#eef1f4;color:#495057}
+  .score{display:inline-block;min-width:34px;text-align:center;padding:2px 7px;border-radius:6px;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}
+  .pill{padding:1px 8px;border-radius:20px;font-size:11px;font-weight:600}
+  .pill.Issued{background:#dcfce7;color:#15803d}.pill.Finaled{background:#f1f5f9;color:#64748b}
+  .pill.Expired{background:#fee2e2;color:#b91c1c}.pill.other{background:#f1f5f9;color:#64748b}
+  .loadmore{display:block;margin:14px auto 40px;padding:9px 22px;border:1px solid var(--teal);background:#fff;color:var(--teal);
+    border-radius:8px;font-weight:600;cursor:pointer}
+  .loadmore:hover{background:var(--teal);color:#fff}
+  .modal-bg{position:fixed;inset:0;background:rgba(20,25,30,.35);display:none;z-index:20}
+  .modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(680px,92vw);max-height:86vh;overflow:auto;
+    background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);z-index:21;display:none}
+  .modal header{display:flex;justify-content:space-between;align-items:center;position:sticky;top:0}
+  .modal h2{font-size:15px;margin:0}
+  .modal .x{cursor:pointer;font-size:20px;color:var(--muted);border:none;background:none}
+  .modal .body{padding:14px 18px}
+  .kv{display:grid;grid-template-columns:150px 1fr;gap:4px 14px;font-size:13px;margin-bottom:14px}
+  .kv .k{color:var(--muted)}
+  .modal a.map{display:inline-block;margin:2px 0 12px;color:var(--teal);font-weight:600;text-decoration:none}
+  pre{background:#0f1720;color:#c5e4de;padding:12px;border-radius:8px;overflow:auto;font-size:11px;max-height:280px}
+  .rawtoggle{cursor:pointer;color:var(--accent);font-size:12px;user-select:none}
 </style>
 </head>
 <body>
 <div class="app">
-  <header class="top">
-    <div class="brand"><b>LA Building Permits</b><span class="tag">City of Los Angeles</span></div>
-    <div class="search">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
-      <input id="q" type="search" placeholder="Search by address…" autocomplete="off" spellcheck="false">
-    </div>
-    <div class="count" id="count">Loading…</div>
-    <div class="ctrl">Sort
-      <select id="sort">
-        <option value="newest">Newest</option>
-        <option value="value_desc">Value high → low</option>
-        <option value="value_asc">Value low → high</option>
-        <option value="oldest">Oldest</option>
-        <option value="address">Address A→Z</option>
-      </select>
-    </div>
-    <div class="ctrl dens">Density
-      <input id="dens" type="range" min="1" max="6" step="1" value="3">
-    </div>
+  <header>
+    <h1>LA <b>Building Permits</b></h1>
+    <input id="q" type="search" placeholder="Search address…" autocomplete="off">
+    <span class="count" id="count">…</span>
+    <span class="grow"></span>
+    <span class="ctl">Row density <input id="density" type="range" min="26" max="46" value="34"></span>
+    <span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
   </header>
-  <div class="chips" id="chips"></div>
-
-  <aside class="rail" id="rail">
-    <div class="rail-hd">Filters</div>
-    <div class="minirow">
-      <label>Min value $<input id="minval" type="number" min="0" step="1000" placeholder="any" inputmode="numeric"></label>
-      <label>Issued since<input id="since" type="date"></label>
-    </div>
-    <div id="facets"></div>
-  </aside>
-
-  <main class="results">
-    <div id="grid" class="grid"></div>
-    <div class="foot"><button id="loadmore" class="loadmore" style="display:none">Load more</button></div>
+  <aside id="rail"></aside>
+  <main>
+    <table id="tbl">
+      <thead><tr id="head"></tr></thead>
+      <tbody id="rows"></tbody>
+    </table>
+    <button class="loadmore" id="more" style="display:none">Load more</button>
   </main>
 </div>
 
-<!-- detail modal -->
-<div class="scrim" id="scrim"></div>
-<div class="modal" id="modal" role="dialog" aria-modal="true">
-  <div class="m-hd" id="mHd">
-    <div class="h-txt"><div class="h-addr" id="mAddr">—</div><div class="h-sub" id="mSub"></div></div>
-    <button class="x" id="mClose" aria-label="Close">&times;</button>
-  </div>
-  <div class="m-bd" id="mBody"></div>
-  <svg class="rz" id="mRz" viewBox="0 0 16 16" fill="currentColor"><path d="M16 16H12L16 12V16ZM16 9H9L16 2V9ZM16 5H13L16 2V5Z" opacity=".6"/><circle cx="14" cy="14" r="1.3"/><circle cx="14" cy="10" r="1.3"/><circle cx="10" cy="14" r="1.3"/></svg>
+<div class="modal-bg" id="mbg"></div>
+<div class="modal" id="modal">
+  <header style="padding:14px 18px;border-bottom:1px solid var(--line)">
+    <h2 id="mtitle">Permit</h2><button class="x" id="mx">×</button>
+  </header>
+  <div class="body" id="mbody"></div>
 </div>
 
 <script>
-const API = location.origin;
-const AUTH = {credentials:'include'};
-const $ = s=>document.querySelector(s);
-const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;};
-
-// ---- filter definitions ----
-const FACETS = [
-  {key:'era',           label:'Era'},
-  {key:'work_type',     label:'Work Type'},
-  {key:'building_type', label:'Building Type'},
-  {key:'status',        label:'Status'},
-  {key:'cd',            label:'Council District'},
-  {key:'zip',           label:'ZIP'},
+const $ = s => document.querySelector(s);
+const LS = { get:(k,d)=>{try{const v=localStorage.getItem('lap_'+k);return v==null?d:JSON.parse(v)}catch{return d}}, set:(k,v)=>localStorage.setItem('lap_'+k,JSON.stringify(v)) };
+
+const ALLCOLS = [
+  {key:'lead_score', label:'Rank', type:'score'},
+  {key:'issued', label:'Issued', type:'date'},
+  {key:'address', label:'Address', type:'addr'},
+  {key:'zip', label:'ZIP', type:'t'},
+  {key:'cd', label:'CD', type:'t'},
+  {key:'work_type', label:'Work Type', type:'badge'},
+  {key:'building_type', label:'Building Type', type:'badge'},
+  {key:'status', label:'Status', type:'status'},
+  {key:'project_value', label:'Project $', type:'money'},
+  {key:'assessed_value', label:'Assessed $', type:'money'},
+  {key:'year_built', label:'Yr Built', type:'t'},
+  {key:'sqft', label:'SqFt', type:'int'},
+  {key:'use_desc', label:'Use', type:'t'},
+  {key:'era', label:'Era', type:'t'},
+  {key:'permit_nbr', label:'Permit #', type:'t'},
 ];
-const ERA_ORDER = {'2020-present':0,'2010-2019':1,'pre-2010':2};
-
-// ---- persisted UI prefs ----
-const LS = {
-  get sort(){return localStorage.getItem('lap.sort')||'newest';},
-  set sort(v){localStorage.setItem('lap.sort',v);},
-  get dens(){return localStorage.getItem('lap.dens')||'3';},
-  set dens(v){localStorage.setItem('lap.dens',v);},
-  get collapsed(){try{return JSON.parse(localStorage.getItem('lap.collapsed'))||{};}catch(e){return{};}},
-  set collapsed(o){localStorage.setItem('lap.collapsed',JSON.stringify(o));},
-};
+let colOrder = LS.get('cols', ALLCOLS.map(c=>c.key));
+if(!Array.isArray(colOrder)||colOrder.length!==ALLCOLS.length) colOrder = ALLCOLS.map(c=>c.key);
+const colDef = k => ALLCOLS.find(c=>c.key===k);
+const cols = () => colOrder.map(colDef).filter(Boolean);
 
-// ---- state ----
-const state = {q:'',era:'',work_type:'',building_type:'',status:'',cd:'',zip:'',min_value:'',since:'',
-  sort:LS.sort, page:1, limit:60, total:0, loaded:0, busy:false};
-const FILTER_KEYS = ['q','era','work_type','building_type','status','cd','zip','min_value','since'];
+let state = { filters:{}, sort:LS.get('sort',{col:'lead_score',dir:'desc'}), page:1, rows:[], total:0 };
 
-// ---- formatting ----
-const nfmt = new Intl.NumberFormat('en-US');
-const money = v=>{const n=Number(v); if(!n||n<=0) return null; return '$'+nfmt.format(Math.round(n));};
-const fmtDate = v=>{ if(!v) return '—'; const d=new Date(v); if(isNaN(d)) return '—';
-  return d.toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); };
-const statusClass = s=>{ const t=(s||'').toLowerCase();
-  if(/final|issued|cofo|complete|approved/.test(t)) return 'ok';
-  if(/expired|revoked|denied|withdrawn|cancel|void|abandon/.test(t)) return 'warn';
-  return 'mut'; };
-const esc = s=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const FACETS = [['era','Era'],['work_type','Work Type'],['building_type','Building Type'],['status','Status'],['cd','Council District'],['zip','ZIP']];
+const money = v => v==null?'':'$'+Number(v).toLocaleString(undefined,{maximumFractionDigits:0});
+const scoreColor = s => s>=75?'#0f766e':s>=55?'#0d9488':s>=35?'#f59e0b':'#94a3b8';
 
-function qs(extra, omit){
+function qs(extra={}){
   const p=new URLSearchParams();
-  FILTER_KEYS.forEach(k=>{ if(k!==omit && state[k]!=='' && state[k]!=null) p.set(k,state[k]); });
-  if(extra) Object.entries(extra).forEach(([k,v])=>p.set(k,v));
+  for(const [k,v] of Object.entries(state.filters)) if(v) p.set(k,v);
+  p.set('sort',state.sort.col); p.set('dir',state.sort.dir);
+  for(const [k,v] of Object.entries(extra)) p.set(k,v);
   return p.toString();
 }
 
-// ---- data loads ----
-// Sequence guards: a slower earlier request must never overwrite a newer render.
-let facetSeq=0, permitSeq=0;
 async function loadFacets(){
-  const my=++facetSeq;
-  try{
-    const r = await fetch(`${API}/api/facets?${qs()}`,AUTH);
-    const data = await r.json();
-    // A facet with an active selection would otherwise collapse to just that value
-    // (server applies every filter to every facet). Refetch each active dimension
-    // WITHOUT its own filter so its alternative values stay switchable.
-    const activeFacetKeys = FACETS.map(f=>f.key).filter(k=>state[k]!=='' && state[k]!=null);
-    await Promise.all(activeFacetKeys.map(async k=>{
-      try{ const alt = await (await fetch(`${API}/api/facets?${qs(null,k)}`,AUTH)).json(); data[k]=alt[k]; }catch(e){}
-    }));
-    if(my!==facetSeq) return;        // superseded by a newer filter change
-    renderFacets(data);
-  }catch(e){ /* keep prior facets on error */ }
-}
-async function loadPermits(reset){
-  if(state.busy && !reset) return;
-  const my = reset ? ++permitSeq : permitSeq;
-  state.busy=true;
-  if(reset){ state.page=1; state.loaded=0; renderSkeleton(); }
-  else { $('#loadmore').disabled=true; $('#loadmore').textContent='Loading…'; }
-  try{
-    const r = await fetch(`${API}/api/permits?${qs({sort:state.sort,page:state.page,limit:state.limit})}`,AUTH);
-    const data = await r.json();
-    if(reset && my!==permitSeq) return;   // a newer reset superseded this one
-    state.total = data.total; state.loaded += data.rows.length;
-    renderCards(data.rows, reset);
-    updateCount();
-    const more = state.loaded < state.total;
-    const lm=$('#loadmore');
-    lm.style.display = more ? '' : 'none';
-    lm.disabled=false; lm.textContent = `Load more (${nfmt.format(state.total-state.loaded)} more)`;
-  }catch(e){
-    if(reset) $('#grid').innerHTML = `<div class="empty">Failed to load permits — ${esc(e.message)}</div>`;
-  }finally{ state.busy=false; }
-}
-function reloadAll(){ loadPermits(true); loadFacets(); syncChips(); }
-
-// ---- render: facets ----
-function renderFacets(data){
-  const host=$('#facets'); const collapsed=LS.collapsed;
-  host.innerHTML='';
-  FACETS.forEach(({key,label})=>{
-    let vals=(data[key]||[]).slice();
-    if(key==='era') vals.sort((a,b)=>(ERA_ORDER[a.v]??9)-(ERA_ORDER[b.v]??9));
-    if(key==='cd')  vals.sort((a,b)=>(+a.v||99)-(+b.v||99));
-    const sel = state[key];
-    const sec = el('div','facet'+(collapsed[key]?' collapsed':'')+(sel?' has-sel':''));
-    const hd = el('button','fhd',
-      `<span class="ttl"><span class="seldot"></span>${label}</span><span class="caret">▼</span>`);
-    hd.onclick=()=>{ const c=LS.collapsed; c[key]=!sec.classList.contains('collapsed'); LS.collapsed=c; sec.classList.toggle('collapsed'); };
-    const body=el('div','fbody');
-    vals.forEach(({v,c})=>{
-      const active = String(sel)===String(v);
-      const b=el('button','fv'+(active?' active':''),
-        `<span class="fname">${esc(v)}</span><span class="fc">${nfmt.format(c)}</span>`);
-      b.title=`${v} · ${nfmt.format(c)}`;
-      b.onclick=()=>{ setFilter(key, active?'':v); };
-      body.appendChild(b);
-    });
-    sec.appendChild(hd); sec.appendChild(body); host.appendChild(sec);
+  const d = await (await fetch('/api/facets?'+qs())).json();
+  const rail = $('#rail'); const collapsed = LS.get('collapsed',{});
+  rail.innerHTML = FACETS.map(([key,label])=>{
+    const items=(d[key]||[]).map(o=>{
+      const active = String(state.filters[key]||'')===String(o.v);
+      return `<li data-k="${key}" data-v="${escapeAttr(o.v)}" class="${active?'active':''}"><span>${escapeHtml(o.v)}</span><span class="c">${Number(o.c).toLocaleString()}</span></li>`;
+    }).join('');
+    return `<div class="facet ${collapsed[key]?'collapsed':''}" data-f="${key}"><h3>${label}<span></span></h3><ul>${items}</ul></div>`;
+  }).join('');
+  rail.querySelectorAll('.facet h3').forEach(h=>h.onclick=()=>{const f=h.parentElement.dataset.f;const c=LS.get('collapsed',{});c[f]=!c[f];LS.set('collapsed',c);h.parentElement.classList.toggle('collapsed');});
+  rail.querySelectorAll('li').forEach(li=>li.onclick=()=>{
+    const {k,v}=li.dataset;
+    state.filters[k] = String(state.filters[k]||'')===v ? '' : v;
+    reload();
   });
 }
 
-function setFilter(key,val){
-  state[key]=val; state.page=1;
-  reloadAll();
+async function loadRows(append=false){
+  const d = await (await fetch('/api/permits?'+qs({page:state.page,limit:100}))).json();
+  state.total = d.total;
+  state.rows = append ? state.rows.concat(d.rows) : d.rows;
+  $('#count').textContent = state.total.toLocaleString()+' permits'+(Object.values(state.filters).some(Boolean)?' (filtered)':'');
+  renderTable();
+  $('#more').style.display = state.rows.length < state.total ? 'block' : 'none';
 }
 
-// ---- render: cards ----
-function renderSkeleton(){ const g=$('#grid'); g.innerHTML=''; for(let i=0;i<9;i++) g.appendChild(el('div','skel')); }
-function renderCards(rows, reset){
-  const g=$('#grid');
-  if(reset){ g.innerHTML=''; if(!rows.length){ g.innerHTML='<div class="empty">No permits match these filters.</div>'; return; } }
-  rows.forEach(r=>g.appendChild(cardEl(r)));
-}
-function cardEl(r){
-  const c=el('button','card'); c.type='button';
-  const m=money(r.project_value);
-  const enrich=[];
-  if(r.assessed_value) enrich.push(`<span>Assessed <b>${money(r.assessed_value)||'—'}</b></span>`);
-  if(r.year_built && Number(r.year_built)>0) enrich.push(`<span>Built <b>${esc(r.year_built)}</b></span>`);
-  if(r.sqft && Number(r.sqft)>0) enrich.push(`<span><b>${nfmt.format(Number(r.sqft))}</b> sqft</span>`);
-  c.innerHTML=`
-    <div class="addr">${esc(r.address||'—')}</div>
-    <div class="badges">
-      ${r.work_type?`<span class="badge wt">${esc(r.work_type)}</span>`:''}
-      ${r.building_type?`<span class="badge bt">${esc(r.building_type)}</span>`:''}
-    </div>
-    ${m?`<div class="val">${m}</div>`:`<div class="val zero">No stated value</div>`}
-    <div class="metarow">
-      <span>${fmtDate(r.issue_date)}</span>
-      <span class="pill ${statusClass(r.status)}">${esc(r.status||'—')}</span>
-    </div>
-    <div class="metarow">
-      <span class="era-tag">${esc(r.era||'')}</span>
-      <span>${r.cd?('CD '+esc(r.cd)):''}${r.zip?(' · '+esc(r.zip)):''}</span>
-    </div>
-    ${enrich.length?`<div class="subrow parcel">${enrich.join('')}</div>`:''}`;
-  c.onclick=()=>openDetail(r.permit_nbr, r);
-  return c;
-}
-function updateCount(){
-  $('#count').innerHTML = `<b>${nfmt.format(state.total)}</b> permit${state.total===1?'':'s'} · showing <b>${nfmt.format(state.loaded)}</b>`;
-}
-
-// ---- active chips ----
-function labelFor(k){return ({q:'Search',era:'Era',work_type:'Work',building_type:'Building',status:'Status',cd:'CD',zip:'ZIP',min_value:'Min $',since:'Since'})[k]||k;}
-function syncChips(){
-  const host=$('#chips'); host.innerHTML='';
-  const active = FILTER_KEYS.filter(k=>state[k]!=='' && state[k]!=null);
-  if(!active.length) return;
-  active.forEach(k=>{
-    let disp=state[k];
-    if(k==='min_value') disp='≥ $'+nfmt.format(state[k]);
-    if(k==='since') disp='since '+fmtDate(state[k]);
-    const chip=el('span','chip',`${labelFor(k)}: ${esc(disp)}<button title="Remove">&times;</button>`);
-    chip.querySelector('button').onclick=()=>clearFilter(k);
-    host.appendChild(chip);
+function renderHead(){
+  const head=$('#head');
+  head.innerHTML = cols().map(c=>{
+    const arr = state.sort.col===c.key ? `<span class="arrow">${state.sort.dir==='asc'?'▲':'▼'}</span>` : '';
+    return `<th draggable="true" data-k="${c.key}"><span class="grip">⠿</span>${c.label}${arr}</th>`;
+  }).join('');
+  head.querySelectorAll('th').forEach(th=>{
+    th.onclick=e=>{ if(e.target.classList.contains('grip'))return; sortBy(th.dataset.k); };
+    th.ondragstart=()=>{drag=th.dataset.k;th.classList.add('drag');};
+    th.ondragend=()=>th.classList.remove('drag');
+    th.ondragover=e=>e.preventDefault();
+    th.ondrop=e=>{e.preventDefault();dropCol(th.dataset.k);};
   });
-  const all=el('button','clr-all',null,'Clear all'); all.textContent='Clear all';
-  all.onclick=clearAll; host.appendChild(all);
 }
-function clearFilter(k){
-  state[k]='';
-  if(k==='q') $('#q').value='';
-  if(k==='min_value') $('#minval').value='';
-  if(k==='since') $('#since').value='';
-  reloadAll();
+let drag=null;
+function dropCol(target){
+  if(!drag||drag===target)return;
+  const a=colOrder.indexOf(drag), b=colOrder.indexOf(target);
+  colOrder.splice(a,1); colOrder.splice(b,0,drag);
+  LS.set('cols',colOrder); drag=null;
+  renderHead(); renderBody();
 }
-function clearAll(){
-  FILTER_KEYS.forEach(k=>state[k]='');
-  $('#q').value=''; $('#minval').value=''; $('#since').value='';
-  reloadAll();
+function sortBy(col){
+  if(state.sort.col===col) state.sort.dir = state.sort.dir==='asc'?'desc':'asc';
+  else state.sort = {col, dir:['address','zip','use_desc','permit_nbr','work_type','building_type','status','era'].includes(col)?'asc':'desc'};
+  LS.set('sort',state.sort); state.page=1; loadRows(false);
 }
-
-// ---- detail modal ----
-const modal=$('#modal'), scrim=$('#scrim');
-async function openDetail(nbr, fallback){
-  modal.classList.add('open'); scrim.classList.add('open');
-  $('#mAddr').textContent = fallback?.address || nbr;
-  $('#mSub').textContent = 'Loading permit '+nbr+'…';
-  $('#mBody').innerHTML = '<div class="loading">Loading detail…</div>';
-  applyModalRig();
-  try{
-    const r=await fetch(`${API}/api/permit/${encodeURIComponent(nbr)}`,AUTH);
-    if(!r.ok) throw new Error('HTTP '+r.status);
-    renderDetail(await r.json());
-  }catch(e){
-    $('#mBody').innerHTML=`<div class="empty">Couldn't load detail — ${esc(e.message)}</div>`;
+function cell(c,r){
+  const v=r[c.key];
+  switch(c.type){
+    case 'score': return `<span class="score" style="background:${scoreColor(v)}">${v==null?'':v}</span>`;
+    case 'date': return v?new Date(v).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'';
+    case 'money': return `<span class="num">${money(v)}</span>`;
+    case 'int': return `<span class="num">${v==null?'':Number(v).toLocaleString()}</span>`;
+    case 'addr': return `<span class="addr">${escapeHtml(v||'')}</span>`;
+    case 'badge': return v?`<span class="badge">${escapeHtml(v)}</span>`:'';
+    case 'status': {const cls=/Issued/.test(v)?'Issued':/Final/.test(v)?'Finaled':/Expired/.test(v)?'Expired':'other';return v?`<span class="pill ${cls}">${escapeHtml(v)}</span>`:''}
+    default: return escapeHtml(v==null?'':String(v));
   }
 }
-function closeDetail(){ modal.classList.remove('open'); scrim.classList.remove('open'); }
-scrim.onclick=closeDetail; $('#mClose').onclick=closeDetail;
-document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDetail(); });
-
-function kvFilter(k,v,text){ // clickable value that applies a filter and closes modal
-  return `<span class="flt" data-fk="${k}" data-fv="${esc(v)}">${esc(text!=null?text:v)}</span>`;
+function renderBody(){
+  const cs=cols();
+  $('#rows').innerHTML = state.rows.map(r=>
+    `<tr data-n="${escapeAttr(r.permit_nbr)}">`+cs.map(c=>`<td>${cell(c,r)}</td>`).join('')+`</tr>`
+  ).join('');
+  $('#rows').querySelectorAll('tr').forEach(tr=>tr.onclick=()=>openDetail(tr.dataset.n));
 }
-function renderDetail(d){
-  $('#mAddr').textContent = d.primary_address || d.permit_nbr;
-  $('#mSub').innerHTML = `${esc(d.permit_nbr)} &middot; ${esc(d.era||'')}`;
-  const val=money(d.valuation);
-  const gmap = (d.lat&&d.lon)?`https://www.google.com/maps/search/?api=1&query=${d.lat},${d.lon}`:null;
-  const rows=[
-    ['Status', `<span class="pill ${statusClass(d.status_desc)}">${esc(d.status_desc||'—')}</span>`],
-    ['Issue date', fmtDate(d.issue_date)],
-    ['Work type', d.permit_type?kvFilter('work_type',d.permit_type):'—'],
-    ['Building type', d.permit_sub_type?kvFilter('building_type',d.permit_sub_type):'—'],
-    ['Permit group', esc(d.permit_group||'—')],
-    ['Use', esc(d.use_desc||'—')],
-    ['Council district', d.council_district?kvFilter('cd',d.council_district,'CD '+d.council_district):'—'],
-    ['ZIP', d.zip_code?kvFilter('zip',d.zip_code):'—'],
-    ['APN / parcel', esc(d.apn||'—')],
-  ];
-  const parcel=[];
-  if(d.parcel_assessed) parcel.push(['Assessed value', money(d.parcel_assessed)||'—']);
-  if(d.parcel_year_built && Number(d.parcel_year_built)>0) parcel.push(['Year built', esc(d.parcel_year_built)]);
-  if(d.parcel_sqft && Number(d.parcel_sqft)>0) parcel.push(['Living area', nfmt.format(Number(d.parcel_sqft))+' sqft']);
-  if(d.parcel_use) parcel.push(['Parcel use', esc(d.parcel_use)]);
-  if(d.parcel_address) parcel.push(['Parcel address', esc(d.parcel_address)]);
+function renderTable(){ renderHead(); renderBody(); }
 
-  let html = `
-    <div class="m-val">${val?`<span class="big">${val}</span><span style="color:var(--ink-faint)">project valuation</span>`:`<span class="big" style="font-size:18px;color:var(--ink-faint)">No stated valuation</span>`}</div>
-    <dl class="kv">${rows.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`;
-  if(d.work_desc){ html+=`<div class="sect">Scope of work</div><div class="desc">${esc(d.work_desc)}</div>`; }
-  if(parcel.length){ html+=`<div class="sect">Parcel enrichment (Assessor 2025)</div><dl class="kv">${parcel.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`; }
-  html+=`<div class="m-actions">
-      ${gmap?`<a class="m-btn primary" href="${gmap}" target="_blank" rel="noopener noreferrer">📍 Open in Google Maps</a>`:''}
-      ${d.source_url?`<a class="m-btn" href="${esc(d.source_url)}" target="_blank" rel="noopener noreferrer">Source record ↗</a>`:''}
-      <button class="m-btn" id="rawToggle">{ } Raw JSON</button>
-    </div>
-    <pre class="raw" id="rawBox">${esc(JSON.stringify(d.raw||d,null,2))}</pre>`;
-  const body=$('#mBody'); body.innerHTML=html;
-  body.querySelector('#rawToggle').onclick=e=>{ $('#rawBox').classList.toggle('open'); e.currentTarget.blur(); };
-  body.querySelectorAll('.flt').forEach(s=>s.onclick=()=>{ setFilter(s.dataset.fk, s.dataset.fv); closeDetail(); });
+async function openDetail(nbr){
+  const r = await (await fetch('/api/permit/'+encodeURIComponent(nbr))).json();
+  $('#mtitle').textContent = (r.primary_address||'Permit')+' — '+nbr;
+  const rows=[
+    ['Lead score', r.lead_score], ['Issued', r.issue_date?new Date(r.issue_date).toLocaleString():''],
+    ['Work type', r.permit_type], ['Building type', r.permit_sub_type], ['Use', r.use_desc],
+    ['Status', r.status_desc], ['Project value', money(r.valuation)], ['Council district', r.cd],
+    ['ZIP', r.zip_code], ['Era', r.era], ['APN', r.apn],
+    ['— Parcel (2025) —',''], ['Assessed value', money(r.parcel_assessed)], ['Year built', r.parcel_year_built],
+    ['SqFt', r.parcel_sqft], ['Use type', r.parcel_use], ['Parcel address', r.parcel_address],
+    ['Scope', r.work_desc],
+  ].filter(([k,v])=>v!=null&&v!=='');
+  const map = (r.lat&&r.lon)?`<a class="map" target="_blank" rel="noopener" href="https://www.google.com/maps?q=${r.lat},${r.lon}">📍 Open in Google Maps</a>`:'';
+  $('#mbody').innerHTML = `<div class="kv">${rows.map(([k,v])=>`<div class="k">${escapeHtml(k)}</div><div>${escapeHtml(String(v))}</div>`).join('')}</div>${map}
+    <div class="rawtoggle" id="rawt">▸ Raw record</div><pre id="raw" style="display:none">${escapeHtml(JSON.stringify(r.raw,null,2))}</pre>`;
+  $('#rawt').onclick=()=>{const p=$('#raw');p.style.display=p.style.display==='none'?'block':'none';};
+  $('#mbg').style.display=$('#modal').style.display='block';
 }
+$('#mx').onclick=$('#mbg').onclick=()=>$('#mbg').style.display=$('#modal').style.display='none';
 
-// ---- modal rig: drag + resize + persist ----
-function applyModalRig(){
-  try{ const s=JSON.parse(localStorage.getItem('lap.modal'));
-    if(s){ modal.style.left=s.left; modal.style.top=s.top; modal.style.transform='none';
-      if(s.w) modal.style.width=s.w; if(s.h) modal.style.height=s.h; } }catch(e){}
-}
-(function rig(){
-  const hd=$('#mHd'), rz=$('#mRz'); let d=null;
-  const save=()=>localStorage.setItem('lap.modal',JSON.stringify({
-    left:modal.style.left,top:modal.style.top,w:modal.style.width,h:modal.style.height}));
-  hd.addEventListener('mousedown',e=>{ if(e.target.closest('.x'))return;
-    const r=modal.getBoundingClientRect(); modal.style.transform='none'; modal.style.left=r.left+'px'; modal.style.top=r.top+'px';
-    d={type:'move',x:e.clientX,y:e.clientY,l:r.left,t:r.top}; e.preventDefault(); });
-  rz.addEventListener('mousedown',e=>{ const r=modal.getBoundingClientRect();
-    d={type:'size',x:e.clientX,y:e.clientY,w:r.width,h:r.height}; e.preventDefault(); e.stopPropagation(); });
-  window.addEventListener('mousemove',e=>{ if(!d)return;
-    if(d.type==='move'){ modal.style.left=(d.l+e.clientX-d.x)+'px'; modal.style.top=Math.max(0,d.t+e.clientY-d.y)+'px'; }
-    else { modal.style.width=Math.max(340,d.w+e.clientX-d.x)+'px'; modal.style.height=Math.max(240,d.h+e.clientY-d.y)+'px'; } });
-  window.addEventListener('mouseup',()=>{ if(d){ save(); d=null; } });
-})();
+function reload(){ state.page=1; loadFacets(); loadRows(false); }
+$('#more').onclick=()=>{ state.page++; loadRows(true); };
+let t; $('#q').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{state.filters.q=e.target.value.trim();reload();},280);};
+$('#density').oninput=e=>{document.documentElement.style.setProperty('--rowh',e.target.value+'px');LS.set('density',e.target.value);};
+$('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
 
-// ---- top-bar wiring ----
-let qTimer;
-$('#q').addEventListener('input',e=>{ clearTimeout(qTimer); qTimer=setTimeout(()=>{ state.q=e.target.value.trim(); reloadAll(); },300); });
-$('#sort').addEventListener('change',e=>{ state.sort=e.target.value; LS.sort=e.target.value; loadPermits(true); });
-$('#dens').addEventListener('input',e=>{ document.documentElement.style.setProperty('--cols',e.target.value); LS.dens=e.target.value; });
-let mvTimer;
-$('#minval').addEventListener('input',e=>{ clearTimeout(mvTimer); mvTimer=setTimeout(()=>{ state.min_value=e.target.value?String(e.target.value):''; reloadAll(); },350); });
-$('#since').addEventListener('change',e=>{ state.since=e.target.value||''; reloadAll(); });
-$('#loadmore').addEventListener('click',()=>{ state.page++; loadPermits(false); });
+function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
+function escapeAttr(s){return escapeHtml(s).replace(/'/g,'&#39;')}
 
-// ---- boot ----
-$('#sort').value=state.sort;
-$('#dens').value=LS.dens; document.documentElement.style.setProperty('--cols',LS.dens);
-reloadAll();
+(function(){
+  const d=LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px'); $('#density').value=d;
+  loadFacets(); loadRows(false);
+})();
 </script>
 </body>
 </html>

← bbdafa5 LA Building Permits viewer frontend: faceted rail, search/so  ·  back to La Socrata Ingester  ·  Viewer: VIEWER_NO_AUTH env bypass for local screenshot/dev ( 2efe81b →