Initial commit
This commit is contained in:
commit
5f2db08b43
100 files changed
+16985
No files matched your search
@@ -0,0 +1,112 @@
|
||||
# Legislative Dashboard
|
||||
|
||||
This is the anonymous + logged-in dashboard for `cali_scraper`, implementing
|
||||
spec section 5 (design documents: `design/API_CONTRACT.md`,
|
||||
`design/DESIGN_DASHBOARD.md`). It is served by a stdlib `http.server`,
|
||||
backed by the SQLite relational DB and the tag-cloud JSON, and produces the
|
||||
frozen API-contract response shapes.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scraper/dashboard/
|
||||
__init__.py # package docstring + version
|
||||
ranking.py # pure I/O-free float-to-top scoring (tested in isolation)
|
||||
context.py # RelevanceContext from user prefs
|
||||
data.py # read-only SQLite access, tag enrichment, committee lookup
|
||||
prefs.py # JSON prefs store (watch/topic toggles, dev auth)
|
||||
resolve.py # ZIP -> district two-tier resolution
|
||||
api.py # API_CONTRACT handlers (no sockets) + 6 empty states
|
||||
server.py # http.server ThreadingHTTPServer + static file routing
|
||||
dashboard_cli.py # guarded CLI entry via cli.py subcommand registration
|
||||
static/
|
||||
index.html # frontend shell
|
||||
style.css # visual system
|
||||
app.js # fetch client, anon/personal rendering, ZIP resolve, watch/opt-in
|
||||
```
|
||||
|
||||
### How it fits the CLI
|
||||
|
||||
`scraper/cli.py` wires in the dashboard with the same guarded pattern as
|
||||
`analyze_cli` and `relate_cli`:
|
||||
|
||||
```python
|
||||
try:
|
||||
from .dashboard import dashboard_cli as _dcli
|
||||
_dcli.register(sub)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
A broken or missing dashboard module never breaks `parse`, `correlate`, or
|
||||
`analyze`.
|
||||
|
||||
### Run it
|
||||
|
||||
```bash
|
||||
python3 -m scraper dashboard --port 8733
|
||||
# browse http://127.0.0.1:8733/
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------------------------|-------|--------------------------------------|
|
||||
| GET | `/api/v1/bills` | any | Bill list, personal / anonymous |
|
||||
| GET | `/api/v1/tags` | any | Tag cloud |
|
||||
| GET | `/api/v1/legislators` | any | Legislators |
|
||||
| GET | `/api/v1/districts` | any | Districts by chamber/zip |
|
||||
| GET | `/api/v1/resolve` | any | ZIP -> district resolution |
|
||||
| POST | `/api/v1/prefs` | auth | Update user prefs |
|
||||
| POST | `/api/v1/auth/register`| none | Dev auth placeholder |
|
||||
| POST | `/api/v1/prefs/watch` | auth | Add/remove watched legislator |
|
||||
| POST | `/api/v1/prefs/topics` | auth | Add/remove opted-in topic |
|
||||
|
||||
### Float-to-top ranking
|
||||
|
||||
Personal view applies a pure scoring function (`ranking.py`) over the bill
|
||||
list:
|
||||
|
||||
- **Watched-legislator match (author)** = +3
|
||||
- **Watched-legislator's committee match** = +2
|
||||
- **Opted-in tag match** = +2 × (number of matched tags)
|
||||
- **Home-chamber match** = +1 (optional)
|
||||
- Bills with score > 0 are moved to the front with a "Relevant to you"
|
||||
marker and a "Floated: ..." reason line
|
||||
- Zero-scored bills remain in their date-grouped order below
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/test_dashboard_ranking.py tests/test_dashboard_api.py
|
||||
```
|
||||
|
||||
32 tests (12 ranking + 20 API), all passing. Pure ranking logic is tested in
|
||||
isolation; API handlers are tested with no sockets via the
|
||||
`handler(req) -> (status, body)` pattern; a live `ThreadingHTTPServer` smoke test
|
||||
confirms static + API endpoints serve.
|
||||
|
||||
## DASHBOARD.md
|
||||
|
||||
This document. The full UX specification is `design/DESIGN_DASHBOARD.md`
|
||||
and the frozen API contract is `design/API_CONTRACT.md`.
|
||||
|
||||
## Renders
|
||||
|
||||
- `out/dashboard_render_anon.png` — anonymous dashboard
|
||||
- `out/dashboard_render_personal.png` — personalized dashboard (ZIP 90210,
|
||||
watch Schiavo, opt-in budget topic)
|
||||
|
||||
Both are data-faithful snapshots from the live API, rendered via macOS
|
||||
`qlmanage` (no headless browser available in this environment; the browser
|
||||
remote-debugging permission popup is a user-level security dialog that
|
||||
cannot be auto-approved).
|
||||
|
||||
## Known limitations / next steps
|
||||
|
||||
- `out/tag_cloud.json` must exist for tags endpoint (118 tags as of
|
||||
2026-09-06).
|
||||
- `0 committee_members` roster — committee lookup degrades gracefully when
|
||||
no roster data is supplied.
|
||||
- Dev auth is a JSON-file local account, not production auth.
|
||||
- ZIP -> district uses a documented demo dataset until roster ETL lands.
|
||||
@@ -0,0 +1,45 @@
|
||||
"""``scraper.dashboard`` — spec §5 (UI build, task t_b38e9af2).
|
||||
|
||||
An anonymous + logged-in legislative dashboard served over the stdlib HTTP
|
||||
server (no heavy framework), backed by the correlation lane's relational DB
|
||||
(``bills`` / ``committees`` / ``members`` / ``committee_members`` /
|
||||
``bill_committees`` + the flat ``daily_file_records`` view) and the LLM lane's
|
||||
``bill_tags`` table.
|
||||
|
||||
Submodules
|
||||
----------
|
||||
* :mod:`scraper.dashboard.ranking` — pure float-to-top ranking + ``hearing_sort``
|
||||
+ ``matched[]`` transparency. No I/O.
|
||||
* :mod:`scraper.dashboard.context` — :class:`RelevanceContext` builder (prefs ->
|
||||
watched-author / watched-committee / opt-in
|
||||
tag sets).
|
||||
* :mod:`scraper.dashboard.data` — read-only DB access (bill events, tag cloud,
|
||||
legislators, districts).
|
||||
* :mod:`scraper.dashboard.prefs` — ``user_prefs`` + watch/topic persistence +
|
||||
a *local/minimal* auth placeholder (D1).
|
||||
* :mod:`scraper.dashboard.resolve` — ZIP/address -> district -> legislators
|
||||
(two-tier, D2), with confidence.
|
||||
* :mod:`scraper.dashboard.api` — request handlers producing the frozen
|
||||
API_CONTRACT.md shapes / errors / empty states.
|
||||
* :mod:`scraper.dashboard.server` — stdlib ``http.server`` + static frontend.
|
||||
* :mod:`scraper.dashboard.dashboard_cli` — guardedly-wired ``dashboard`` CLI
|
||||
subcommand.
|
||||
|
||||
FROZEN CONTRACTS honoured (task t_b38e9af2 / parent t_21cd58a9):
|
||||
|
||||
* Python 3.9.6, stdlib only — no new third-party dependency (the server uses
|
||||
``http.server`` / ``urllib``, mirroring the LLM lane's degradation posture).
|
||||
* Field names match ``design/API_CONTRACT.md`` verbatim.
|
||||
* The ranking model is DESIGN_DASHBOARD §4: a bill floats to the top iff
|
||||
``author ∩ watched_legislators ≢ ∅`` OR ``bill.tags ∩ opted_in_tags ≢ ∅``.
|
||||
* Never fabricate: a committee with no roster produces no committee membership
|
||||
(inherited from the correlation lane); the committee-of-watched path simply
|
||||
does not fire for such bills (degrades to author + tag only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["ranking", "context", "data", "prefs", "resolve", "api", "server"]
|
||||
|
||||
#: Default personalization band size (DESIGN_DASHBOARD §4: "top N = 10").
|
||||
PERSONAL_BAND_N = 10
|
||||
@@ -0,0 +1,438 @@
|
||||
# noqa: E501
|
||||
"""API handlers producing the frozen API_CONTRACT.md shapes (spec §5).
|
||||
|
||||
Handler(req) -> (status, body). Driven by the stdlib HTTP server and by unit
|
||||
tests with no sockets. A Services bundle carries collaborators so handlers stay
|
||||
pure of instantiation. Errors per §6; empty results are 200 with empty lists.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from . import data as _data
|
||||
from . import ranking
|
||||
from . import resolve as _resolve
|
||||
from .data import (load_tag_cloud, list_events,
|
||||
load_legislators, committee_roster_for)
|
||||
from .ranking import hearing_sort, full_queue, personal_band
|
||||
from .context import from_prefs
|
||||
from .prefs import PrefsStore, LocalAuth
|
||||
|
||||
# error model (§6)
|
||||
|
||||
def api_error(code, message, status=400, field=None):
|
||||
err = {"error": {"code": code, "message": message}}
|
||||
if field:
|
||||
err["error"]["field"] = field
|
||||
return status, err
|
||||
|
||||
|
||||
# service bundle
|
||||
|
||||
class Services:
|
||||
|
||||
def __init__(self, db_path, prefs, auth):
|
||||
self.db_path = db_path
|
||||
self.prefs = prefs
|
||||
self.auth = auth
|
||||
|
||||
@classmethod
|
||||
def default(cls, db_path=None,
|
||||
prefs_path="out/prefs.json",
|
||||
auth_path="out/auth.json"):
|
||||
from .. import relational
|
||||
db = db_path or relational.DEFAULT_DB
|
||||
return cls(db_path=db,
|
||||
prefs=PrefsStore(prefs_path),
|
||||
auth=LocalAuth(auth_path))
|
||||
|
||||
def prefs_for_user(self, user_id):
|
||||
if not user_id:
|
||||
return {}
|
||||
return self.prefs.get(user_id)
|
||||
|
||||
def user_from_token(self, token):
|
||||
if not token:
|
||||
return None
|
||||
return self.auth.verify_token(token)
|
||||
|
||||
|
||||
# request
|
||||
|
||||
class Request:
|
||||
|
||||
def __init__(self, method, path, query=None, body=None,
|
||||
token=None, services=None, user_id=None):
|
||||
self.method = str(method).upper()
|
||||
self.path = str(path).lstrip("/")
|
||||
self.query = query or {}
|
||||
self.body = body or {}
|
||||
self.token = token
|
||||
self.services = services
|
||||
self.user_id = user_id
|
||||
|
||||
@property
|
||||
def svc(self):
|
||||
if self.services is None:
|
||||
raise RuntimeError("Request built without a Services bundle.")
|
||||
return self.services
|
||||
|
||||
def require_auth(self):
|
||||
uid = self.user_id or self.svc.user_from_token(self.token)
|
||||
if not uid:
|
||||
return None, api_error("not_authenticated",
|
||||
"A bearer token is required for this endpoint.",
|
||||
401)
|
||||
self.user_id = uid
|
||||
return uid, None
|
||||
|
||||
|
||||
# query helpers
|
||||
|
||||
def q_list(key, query):
|
||||
v = query.get(key)
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [str(x).strip() for x in v if str(x).strip()]
|
||||
return [s.strip() for s in str(v).split(",") if s.strip()]
|
||||
|
||||
|
||||
def _now_iso():
|
||||
import datetime
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _truthy(v):
|
||||
if v is None:
|
||||
return False
|
||||
if isinstance(v, str):
|
||||
return v.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(v)
|
||||
|
||||
|
||||
# GET /api/v1/bills (§1/§2)
|
||||
|
||||
def handle_bills(req):
|
||||
q = req.query
|
||||
events = list_events(
|
||||
db_path=req.svc.db_path,
|
||||
upcoming=_truthy(q.get("upcoming")),
|
||||
from_date=q.get("from") or q.get("date_from"),
|
||||
to_date=q.get("to") or q.get("date_to"),
|
||||
chamber=q.get("chamber"),
|
||||
tags=q_list("tags", q),
|
||||
q=q.get("q"),
|
||||
author=q.get("author"),
|
||||
as_of=q.get("as_of"),
|
||||
)
|
||||
if _truthy(q.get("personal")):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
prefs = req.svc.prefs_for_user(uid)
|
||||
roster = committee_roster_for(
|
||||
req.svc.db_path,
|
||||
set(prefs.get("watched_legislators") or []))
|
||||
ctx = from_prefs(prefs, committee_roster=roster)
|
||||
for ev in events:
|
||||
ev["relevance"] = ctx.score(ev)
|
||||
if _truthy(q.get("full")):
|
||||
events = full_queue(events)
|
||||
else:
|
||||
events = personal_band(
|
||||
events, int(q.get("band_n") or ranking.PERSONAL_BAND_N))
|
||||
session = _detect_session(events, req.svc.db_path)
|
||||
items = [_client_bill(e) for e in events]
|
||||
groups = {}
|
||||
for e in items:
|
||||
key = e.get("date") or "TBD"
|
||||
groups[key] = groups.get(key, 0) + 1
|
||||
chamber_avail = _chamber_availability(items, req.svc.db_path)
|
||||
body = {
|
||||
"session": session,
|
||||
"as_of": q.get("as_of") or _now_iso(),
|
||||
"chamber_availability": chamber_avail,
|
||||
"items": items,
|
||||
"groups": groups,
|
||||
}
|
||||
return 200, body
|
||||
|
||||
|
||||
def _client_bill(ev):
|
||||
out = {k: ev.get(k) for k in _data._EVENT_FIELDS}
|
||||
out["hearing_sort"] = hearing_sort(ev.get("hearing_time"))
|
||||
out["tags"] = ev.get("tags")
|
||||
if "relevance" in ev:
|
||||
out["relevance"] = ev["relevance"]
|
||||
return out
|
||||
|
||||
|
||||
def _detect_session(events, db_path):
|
||||
for e in events:
|
||||
if e.get("session"):
|
||||
return str(e["session"])
|
||||
return "2025-2026"
|
||||
|
||||
|
||||
def _chamber_availability(items, db_path):
|
||||
have = {str(e.get("chamber_origin") or "ASSEMBLY").upper() for e in items}
|
||||
if not have:
|
||||
for e in list_events(db_path=db_path):
|
||||
have.add(str(e.get("chamber_origin") or "ASSEMBLY").upper())
|
||||
out = {}
|
||||
for ch in ("ASSEMBLY", "SENATE"):
|
||||
present = ch in have or any(h == ch for h in have)
|
||||
out[ch] = "available" if present else "pending"
|
||||
return out
|
||||
|
||||
|
||||
# GET /api/v1/tags (§4.1)
|
||||
|
||||
def handle_tags(req):
|
||||
top = req.query.get("top")
|
||||
top = int(top) if top and str(top).isdigit() else None
|
||||
return 200, load_tag_cloud(req.svc.db_path, top=top)
|
||||
|
||||
|
||||
# GET /api/v1/legislators (§3.2 / §4.2)
|
||||
|
||||
def handle_legislators(req):
|
||||
q = req.query.get("q")
|
||||
if isinstance(q, list):
|
||||
q = q[0]
|
||||
return 200, {"items": load_legislators(req.svc.db_path, q=q)}
|
||||
|
||||
|
||||
# GET /api/v1/districts (§4.2)
|
||||
|
||||
def handle_districts(req):
|
||||
seen = {}
|
||||
for entry in _resolve.DEMO_ZIPS.values():
|
||||
for d in entry:
|
||||
seen[d["district_id"]] = d
|
||||
items = [
|
||||
{"district_id": d["district_id"], "type": d["type"],
|
||||
"number": d.get("number"),
|
||||
"assembly_member": d.get("assembly_member"),
|
||||
"senate_member": d.get("senate_member")}
|
||||
for d in seen.values()
|
||||
]
|
||||
return 200, {"items": sorted(items, key=lambda x: x["district_id"])}
|
||||
|
||||
|
||||
# POST /api/v1/districts/resolve (§3.1 — proposes, never persists)
|
||||
|
||||
def handle_resolve(req):
|
||||
b = req.body or {}
|
||||
zip_ = b.get("zip")
|
||||
address = b.get("address")
|
||||
district = b.get("district")
|
||||
if not zip_ and not address and not district:
|
||||
return api_error("invalid_zip",
|
||||
"Provide one of: zip, address, or district.",
|
||||
400, field="zip")
|
||||
result = _resolve.resolve(zip=zip_, address=address, district=district)
|
||||
conf = result["confidence"]
|
||||
if conf == "out-of-state":
|
||||
return api_error("out_of_state",
|
||||
"That input is not in California.",
|
||||
400, field="zip")
|
||||
if conf == "zero":
|
||||
return api_error("district_not_found",
|
||||
"Could not match the input to a California district.",
|
||||
404, field="zip" if zip_ else "address")
|
||||
if conf == "ambiguous":
|
||||
out = dict(result)
|
||||
out["note"] = ("Multiple districts match; confirm one before "
|
||||
"watching (never auto-picked).")
|
||||
return 200, out
|
||||
return 200, result
|
||||
|
||||
|
||||
# /api/v1/prefs (§3.3)
|
||||
|
||||
def handle_get_prefs(req):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
prefs = req.svc.prefs_for_user(uid)
|
||||
if not prefs:
|
||||
return api_error("prefs_not_set",
|
||||
"No preferences stored; set a ZIP / watch / topics.",
|
||||
404)
|
||||
prefs = dict(prefs)
|
||||
prefs["user_id"] = uid
|
||||
prefs["updated_at"] = prefs.get("updated_at") or _now_iso()
|
||||
return 200, prefs
|
||||
|
||||
|
||||
def handle_put_prefs(req):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
b = dict(req.body or {})
|
||||
allowed = {"zip", "address", "home_chamber", "districts",
|
||||
"watched_legislators", "opted_in_tags"}
|
||||
valid_tags = _valid_tag_set(req)
|
||||
known_legs = {x["legislator_id"] for x in
|
||||
load_legislators(req.svc.db_path)}
|
||||
for k, v in b.items():
|
||||
if k not in allowed:
|
||||
return api_error("invalid_config",
|
||||
"Unknown prefs field: %s" % k, 400, field=k)
|
||||
if k == "opted_in_tags":
|
||||
for t in v:
|
||||
if str(t).lower() not in valid_tags:
|
||||
return api_error("tag_unknown",
|
||||
"Unknown topic: %s" % t, 400,
|
||||
field="opted_in_tags")
|
||||
if k == "watched_legislators":
|
||||
for w in v:
|
||||
if known_legs and w not in known_legs:
|
||||
return api_error("legislator_unknown",
|
||||
"Unknown legislator: %s" % w, 400,
|
||||
field="watched_legislators")
|
||||
clean = {k: b[k] for k in b if k in allowed}
|
||||
clean["updated_at"] = _now_iso()
|
||||
updated = req.svc.prefs.put(uid, clean)
|
||||
updated["user_id"] = uid
|
||||
return 200, updated
|
||||
|
||||
|
||||
def _valid_tag_set(req):
|
||||
tags = load_tag_cloud(req.svc.db_path)["tags"]
|
||||
return {t["tag_id"].split(":", 1)[-1].lower() for t in tags}
|
||||
|
||||
|
||||
# watch / topic toggles (§3.4 / §3.5 — idempotent)
|
||||
|
||||
def handle_add_watch(req):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
b = req.body or {}
|
||||
lid = b.get("legislator_id") or b.get("id")
|
||||
if not lid:
|
||||
return api_error("invalid_legislator",
|
||||
"legislator_id is required.", 400,
|
||||
field="legislator_id")
|
||||
if not _known_legislator(req, lid):
|
||||
return api_error("legislator_unknown",
|
||||
"Unknown legislator: %s" % lid, 400, field="id")
|
||||
prefs = req.svc.prefs.add_watch(uid, lid)
|
||||
prefs["user_id"] = uid
|
||||
return 201, prefs
|
||||
|
||||
|
||||
def handle_remove_watch(req, legislator_id):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
prefs = req.svc.prefs.remove_watch(uid, legislator_id)
|
||||
prefs["user_id"] = uid
|
||||
return 200, prefs
|
||||
|
||||
|
||||
def handle_add_topic(req):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
b = req.body or {}
|
||||
tag = b.get("tag") or b.get("tag_id") or b.get("id")
|
||||
if not tag:
|
||||
return api_error("invalid_tag", "tag is required.", 400, field="tag")
|
||||
if str(tag).lower() not in _valid_tag_set(req):
|
||||
return api_error("tag_unknown",
|
||||
"Unknown topic: %s" % tag, 400, field="tag")
|
||||
prefs = req.svc.prefs.add_topic(uid, str(tag).lower())
|
||||
prefs["user_id"] = uid
|
||||
return 201, prefs
|
||||
|
||||
|
||||
def handle_remove_topic(req, tag):
|
||||
uid, err = req.require_auth()
|
||||
if err:
|
||||
return err
|
||||
prefs = req.svc.prefs.remove_topic(uid, tag)
|
||||
prefs["user_id"] = uid
|
||||
return 200, prefs
|
||||
|
||||
|
||||
def _known_legislator(req, lid):
|
||||
return any(x.get("legislator_id") == lid
|
||||
for x in load_legislators(req.svc.db_path))
|
||||
|
||||
|
||||
# Minimal local-account auth (D1a default — dev placeholder)
|
||||
|
||||
def handle_register(req):
|
||||
email = (req.body or {}).get("email", "").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
return api_error("invalid_email",
|
||||
"A valid email is required.", 400, field="email")
|
||||
rec = req.svc.auth.register(email)
|
||||
req.svc.prefs.put(rec["user_id"], {"email": rec["email"]})
|
||||
return 201, rec
|
||||
|
||||
|
||||
def handle_whoami(req):
|
||||
uid = req.user_id or req.svc.user_from_token(req.token)
|
||||
if not uid:
|
||||
return {"user_id": None}
|
||||
return {"user_id": uid, "prefs": req.svc.prefs_for_user(uid)}
|
||||
|
||||
|
||||
# router
|
||||
|
||||
def _strip(req):
|
||||
p = req.path
|
||||
base = "api/v1/"
|
||||
return p[len(base):] if p.startswith(base) else p
|
||||
|
||||
|
||||
def run(req):
|
||||
m = req.method
|
||||
p2 = _strip(req)
|
||||
if m == "POST" and p2 == "auth/register":
|
||||
return handle_register(req)
|
||||
if m == "GET" and p2 == "whoami":
|
||||
return 200, handle_whoami(req)
|
||||
if m == "GET" and p2 == "bills":
|
||||
return handle_bills(req)
|
||||
if m == "GET" and p2 == "tags":
|
||||
return handle_tags(req)
|
||||
if m == "GET" and p2 == "legislators":
|
||||
return handle_legislators(req)
|
||||
if m == "GET" and p2 == "districts":
|
||||
return handle_districts(req)
|
||||
if m == "POST" and p2 == "districts/resolve":
|
||||
return handle_resolve(req)
|
||||
if m == "GET" and p2 == "prefs":
|
||||
return handle_get_prefs(req)
|
||||
if m == "PUT" and p2 == "prefs":
|
||||
return handle_put_prefs(req)
|
||||
if m == "POST" and p2 == "prefs/watch":
|
||||
return handle_add_watch(req)
|
||||
if m == "DELETE" and p2.startswith("prefs/watch/"):
|
||||
return handle_remove_watch(req, p2.split("/", 3)[2])
|
||||
if m == "POST" and p2 == "prefs/topics":
|
||||
return handle_add_topic(req)
|
||||
if m == "DELETE" and p2.startswith("prefs/topics/"):
|
||||
return handle_remove_topic(req, p2.split("/", 3)[2])
|
||||
if m == "GET" and p2 == "":
|
||||
return 200, {"endpoints": [
|
||||
"GET /api/v1/bills?upcoming=1&personal=1",
|
||||
"GET /api/v1/tags",
|
||||
"GET /api/v1/legislators?q=",
|
||||
"GET /api/v1/districts",
|
||||
"POST /api/v1/districts/resolve",
|
||||
"GET/PUT /api/v1/prefs",
|
||||
"POST/DELETE /api/v1/prefs/watch[/:id]",
|
||||
"POST/DELETE /api/v1/prefs/topics[/:id]",
|
||||
"POST /api/v1/auth/register",
|
||||
"GET /api/v1/whoami",
|
||||
]}
|
||||
return api_error("not_found",
|
||||
"No such endpoint: %s %s" % (m, req.path), 404)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Build a relevance context from a user's preferences (spec §5, DESIGN §4/§10).
|
||||
|
||||
The :class:`RelevanceContext` is the pure input to :func:`ranking.score_bill`.
|
||||
It is built from a user's stored preferences plus (optionally) the correlation
|
||||
lane's committee<->member join, so the "committee of a watched legislator"
|
||||
float path fires *only* when a real roster-backed committee exists for the
|
||||
bill (never fabricated).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .ranking import Weights, DEFAULT_WEIGHTS
|
||||
|
||||
|
||||
def _slug(name):
|
||||
if not name:
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") or "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelevanceContext:
|
||||
"""Pure input to the ranking for one logged-in user.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
watched_authors: lower-cased slugs of the primary author of each watched
|
||||
legislator (``author ∩ watched_legislators``).
|
||||
watched_committees: slugs of committees served by a watched legislator
|
||||
(committee-of-watched-legislator path). Built by :func:`from_prefs`
|
||||
when a committee-roster lookup is supplied; otherwise empty.
|
||||
opted_in_tags: lower-cased tag strings (``tags ∩ opted_in_tags``).
|
||||
home_chamber: optional chamber hint for the +1 score term.
|
||||
weights: scoring weights (tunable — Open Decision D3).
|
||||
"""
|
||||
|
||||
watched_authors: Set[str] = field(default_factory=set)
|
||||
watched_committees: Set[str] = field(default_factory=set)
|
||||
opted_in_tags: Set[str] = field(default_factory=set)
|
||||
home_chamber: Optional[str] = None
|
||||
weights: Weights = field(default_factory=lambda: DEFAULT_WEIGHTS)
|
||||
|
||||
def score(self, bill: Dict) -> Dict:
|
||||
"""Score one bill against this context."""
|
||||
from .ranking import score_bill
|
||||
return score_bill(
|
||||
bill,
|
||||
self.watched_authors,
|
||||
self.watched_committees,
|
||||
self.opted_in_tags,
|
||||
home_chamber=self.home_chamber,
|
||||
weights=self.weights,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_personalised(self) -> bool:
|
||||
"""True when the user has set a watch or opt-in (DESIGN §6.3 gate)."""
|
||||
return bool(self.watched_authors or self.watched_committees
|
||||
or self.opted_in_tags)
|
||||
|
||||
|
||||
def from_prefs(prefs,
|
||||
committee_roster=None,
|
||||
weights=None) -> RelevanceContext:
|
||||
"""Build a :class:`RelevanceContext` from a stored preferences blob.
|
||||
|
||||
``prefs`` matches API_CONTRACT §3.3::
|
||||
|
||||
{ "watched_legislators": ["L-asm-14", ...],
|
||||
"opted_in_tags": ["waste", "health"],
|
||||
"home_chamber": "ASSEMBLY",
|
||||
"districts": [...] }
|
||||
|
||||
``committee_roster`` optionally maps a ``legislator_id`` -> list of
|
||||
committee slugs that legislator chairs/serves in, so the
|
||||
committee-of-watched path can be derived. It is looked up against the
|
||||
correlation lane by the caller (:mod:`scraper.dashboard.data`); when
|
||||
absent the committee path is empty (honest degradation — the correlation
|
||||
lane found no roster, and we never fabricate membership).
|
||||
"""
|
||||
prefs = prefs or {}
|
||||
ctx = RelevanceContext(weights=weights or DEFAULT_WEIGHTS)
|
||||
|
||||
# Key the author-matching set on both the id-as-given and its slug so a
|
||||
# bill whose `author` column is the *name* still matches a name-based watch.
|
||||
for lid in prefs.get("watched_legislators") or []:
|
||||
s = _slug(lid)
|
||||
if s and s != "unknown":
|
||||
ctx.watched_authors.add(s)
|
||||
if isinstance(lid, str) and lid.strip():
|
||||
ctx.watched_authors.add(lid.strip().lower())
|
||||
|
||||
for tag in prefs.get("opted_in_tags") or []:
|
||||
t = str(tag).strip().lower()
|
||||
if t:
|
||||
ctx.opted_in_tags.add(t)
|
||||
|
||||
hc = prefs.get("home_chamber")
|
||||
if hc:
|
||||
ctx.home_chamber = str(hc).lower()
|
||||
|
||||
# Resolve committee-of-watched legs from the roster map (never fabricated).
|
||||
if committee_roster:
|
||||
for lid in prefs.get("watched_legislators") or []:
|
||||
committees = committee_roster.get(lid)
|
||||
if committees:
|
||||
for c in committees:
|
||||
cs = _slug(c)
|
||||
if cs and cs != "unknown":
|
||||
ctx.watched_committees.add(cs)
|
||||
return ctx
|
||||
@@ -0,0 +1,84 @@
|
||||
"""`dashboard` CLI subcommand (spec §5, task t_b38e9af2).
|
||||
|
||||
Wires the dashboard server into the shared `scraper/cli.py` ADDITIVELY +
|
||||
DEFENSIVELY, mirroring parser/correlate/analyze (the guarded `register()`
|
||||
pattern): `cli.py` calls `register(sub)` inside a try/except and adds its
|
||||
handler without touching any sibling handler. The `dashboard` name does not
|
||||
collide.
|
||||
|
||||
This is the CLI/app entry point per the task contract. The server itself lives
|
||||
in `scraper/dashboard/server.py`; this module just parses flags and calls
|
||||
`server.serve(...)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .. import relational
|
||||
from . import server as _server
|
||||
|
||||
log = logging.getLogger("scraper.dashboard_cli")
|
||||
|
||||
|
||||
def _already_registered(sub, name: str) -> bool:
|
||||
actions = getattr(sub, "_choices_actions", [])
|
||||
return any(getattr(a, "name", None) == name for a in actions)
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
"""Register the `dashboard` subcommand (guarded — skip if a sibling owns it)."""
|
||||
if _already_registered(sub, "dashboard"):
|
||||
return
|
||||
pd = sub.add_parser(
|
||||
"dashboard",
|
||||
help="serve the legislative dashboard (API + static UI)",
|
||||
description=("Serve the legislative dashboard. Reads the relational DB "
|
||||
"(bills/committees/members + bill_tags) and serves the "
|
||||
"API_CONTRACT.md endpoints + the static frontend."),
|
||||
)
|
||||
pd.add_argument("--host", default="127.0.0.1",
|
||||
help="bind host (default 127.0.0.1)")
|
||||
pd.add_argument("--port", type=int, default=8000,
|
||||
help="bind port (default 8000)")
|
||||
pd.add_argument("--db", default=relational.DEFAULT_DB,
|
||||
help="SQLite path (default db/daily_file.sqlite)")
|
||||
pd.add_argument("--prefs", default="out/prefs.json",
|
||||
help="preferences JSON file (default out/prefs.json)")
|
||||
pd.add_argument("--auth", default="out/auth.json",
|
||||
help="local-auth JSON file (default out/auth.json)")
|
||||
pd.add_argument("--no-static", dest="static", action="store_false",
|
||||
help="API only, no static frontend")
|
||||
pd.add_argument("--log", action="store_true",
|
||||
help="log HTTP requests to stderr")
|
||||
pd.set_defaults(func=handle_dashboard)
|
||||
|
||||
|
||||
def handle_dashboard(args) -> int:
|
||||
"""CLI handler for the `dashboard` subcommand."""
|
||||
return _server.serve(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
db_path=args.db,
|
||||
prefs_path=args.prefs,
|
||||
auth_path=args.auth,
|
||||
serve_static=getattr(args, "static", True),
|
||||
log_on=getattr(args, "log", False),
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""Direct invocation: `python -m scraper.dashboard_cli`."""
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(
|
||||
prog="scraper.dashboard_cli",
|
||||
description="Serve the legislative dashboard.")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
register(sub)
|
||||
args = p.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Read-only access to the dashboard's data sources (spec §5).
|
||||
|
||||
The dashboard never mutates the relational DB. It reads:
|
||||
|
||||
* the flat ``v_daily_file_records`` view (or ``daily_file_records``) for the
|
||||
upcoming-events queue — one bill event per ``(date, bill_id)``, enriched
|
||||
with per-bill ``tags`` from the LLM lane's ``bill_tags`` table;
|
||||
* ``bill_tags`` for the tag cloud (§4.1);
|
||||
* ``members`` / ``committees`` / ``bill_committees`` / ``committee_members`` for
|
||||
the legislators reference data (§3.2 / §4.2) and the
|
||||
committee-of-watched-legislator float path.
|
||||
|
||||
Everything here returns plain JSON-serialisable dicts so the API layer can
|
||||
ship them verbatim into the API_CONTRACT shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from .. import relational
|
||||
|
||||
FLAT_TABLE = relational.FLAT_TABLE
|
||||
VIEW_NAME = "v_" + FLAT_TABLE
|
||||
|
||||
# Fields a bill event carries into the API_CONTRACT §1 Bill shape.
|
||||
# `tags` is injected separately (from bill_tags), not a stored column.
|
||||
_EVENT_FIELDS = (
|
||||
"bill_id", "bill_number", "chamber_origin", "author", "short_title",
|
||||
"description", "committee", "hearing_time", "location", "date",
|
||||
"session", "item_number", "supplement", "source",
|
||||
)
|
||||
|
||||
|
||||
def _slug(name: Optional[str]) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") or "unknown"
|
||||
|
||||
|
||||
def _title_case(s: str) -> str:
|
||||
return " ".join(w[:1].upper() + w[1:] for w in s.split() if w)
|
||||
|
||||
|
||||
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||
cur = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _view_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||
cur = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='view' AND name=?", (name,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _flat_source(conn: sqlite3.Connection) -> str:
|
||||
"""Prefer the flat-preserving view (the QA contract); fall back to the table."""
|
||||
return VIEW_NAME if _view_exists(conn, VIEW_NAME) else FLAT_TABLE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags (LLM lane: bill_tags table, §4.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_bill_tags(db_path: str = relational.DEFAULT_DB) -> Dict[str, List[str]]:
|
||||
"""Map ``bill_id`` -> [tag, ...] from the ``bill_tags`` table.
|
||||
|
||||
Absent/empty table -> ``{}`` so a bill with no analysis degrades to
|
||||
``tags: []`` (DESIGN §6.6 — no chips, ranking by legislator only).
|
||||
"""
|
||||
tags: Dict[str, List[str]] = {}
|
||||
if not os.path.exists(db_path):
|
||||
return tags
|
||||
conn = relational.connect(db_path, foreign_keys=False)
|
||||
try:
|
||||
if not _table_exists(conn, "bill_tags"):
|
||||
return tags
|
||||
rows = conn.execute(
|
||||
"SELECT bill_id, tag FROM bill_tags "
|
||||
"WHERE bill_id IS NOT NULL AND tag IS NOT NULL AND tag != '' "
|
||||
"ORDER BY bill_id, tag").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
bid = r["bill_id"]
|
||||
tag = r["tag"]
|
||||
bucket = tags.setdefault(bid, [])
|
||||
if tag not in bucket:
|
||||
bucket.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
def load_tag_cloud(db_path: str = relational.DEFAULT_DB,
|
||||
as_of: Optional[str] = None,
|
||||
top: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""The /tags cloud (§4.1): ``[{tag_id, label, count}, ...]`` sorted desc.
|
||||
|
||||
Count = number of bills carrying the tag. May be sparse early (LLM lane
|
||||
warming) — the UI shows a "filling in" hint, never a 0-count chip.
|
||||
"""
|
||||
import datetime
|
||||
conn = relational.connect(db_path, foreign_keys=False)
|
||||
try:
|
||||
if not _table_exists(conn, "bill_tags"):
|
||||
data = []
|
||||
else:
|
||||
data = conn.execute(
|
||||
"SELECT tag, COUNT(DISTINCT bill_id) AS n FROM bill_tags "
|
||||
"WHERE tag IS NOT NULL AND tag != '' GROUP BY tag "
|
||||
"ORDER BY n DESC, tag").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = []
|
||||
for r in data:
|
||||
out.append({
|
||||
"tag_id": "tag:" + str(r["tag"]),
|
||||
"label": _title_case(str(r["tag"])),
|
||||
"count": int(r["n"]),
|
||||
})
|
||||
if top:
|
||||
out = out[:top]
|
||||
return {
|
||||
"as_of": as_of or _now_iso(),
|
||||
"tags": out,
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
import datetime
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upcoming-events queue (the daily-file calendar, §2 / §5 "upcoming")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _in_window(date: Optional[str], frm: Optional[str], to: Optional[str],
|
||||
as_of: Optional[str]) -> bool:
|
||||
"""API_CONTRACT §5 'upcoming' gate on event_date (= date here).
|
||||
|
||||
* a T-1 day (date < as_of) is EXCLUDED when an as_of floor is set;
|
||||
* 'this week'/'this month' are from/to windows over event_date.
|
||||
Dates are ISO ``YYYY-MM-DD`` so string compare == chronological.
|
||||
"""
|
||||
if not date:
|
||||
return not frm and not to
|
||||
if frm and date < frm:
|
||||
return False
|
||||
if to and date > to:
|
||||
return False
|
||||
if as_of and date < as_of:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_events(db_path: str = relational.DEFAULT_DB,
|
||||
upcoming: bool = False,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
chamber: Optional[str] = None,
|
||||
tags: Optional[Sequence[str]] = None,
|
||||
q: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
as_of: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Build the bill-event queue, one event per ``(date, bill_id)``.
|
||||
|
||||
* ``upcoming=True`` + ``as_of`` -> keep only future-dated events
|
||||
(API_CONTRACT §5). When the caller omits ``as_of`` the default is the
|
||||
latest date present, so a demo on a single historical day still shows its
|
||||
queue; pin ``as_of`` for a true "future-of-now" view.
|
||||
* dedupe *within* a date by ``bill_id`` (multiple agenda items the same day
|
||||
collapse to one); across dates they are distinct events.
|
||||
* enriched with per-bill ``tags`` from the LLM lane.
|
||||
* optional filters: chamber, tags (any-match), free-text ``q``, author.
|
||||
|
||||
Returns list of dicts matching API_CONTRACT §1 **without** the ``relevance``
|
||||
block (the API layer adds that for logged-in views).
|
||||
"""
|
||||
conn = relational.connect(db_path, foreign_keys=False)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM " + _flat_source(conn) +
|
||||
" WHERE bill_id IS NOT NULL AND bill_id != '' "
|
||||
"ORDER BY date, house, bill_id, committee, id").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
flat = [dict(r) for r in rows]
|
||||
|
||||
bill_tags = load_bill_tags(db_path)
|
||||
tag_filter = {t.strip().lower() for t in (tags or []) if str(t).strip()}
|
||||
ql = (q or "").strip().lower()
|
||||
al = (author or "").strip().lower()
|
||||
|
||||
if as_of is None:
|
||||
dates = sorted({r["date"] for r in flat if r.get("date")})
|
||||
as_of = dates[-1] if dates else None
|
||||
|
||||
# Collapse to one event per (date, bill_id); prefer a row carrying a hearing.
|
||||
seen: Dict[tuple, Dict[str, Any]] = {}
|
||||
order: List[tuple] = []
|
||||
for r in flat:
|
||||
key = (r.get("date"), r.get("bill_id"))
|
||||
if key in seen:
|
||||
if not seen[key].get("_has_time") and r.get("hearing_time"):
|
||||
seen[key] = r
|
||||
continue
|
||||
r["_has_time"] = bool(r.get("hearing_time"))
|
||||
seen[key] = r
|
||||
order.append(key)
|
||||
|
||||
events = []
|
||||
for key in order:
|
||||
r = seen[key]
|
||||
origin = str(r.get("chamber_origin") or r.get("house") or "").lower()
|
||||
if chamber and origin != chamber.lower():
|
||||
continue
|
||||
if not _in_window(r.get("date"), from_date, to_date, as_of):
|
||||
continue
|
||||
btags = bill_tags.get(r["bill_id"], [])
|
||||
if tag_filter and not (tag_filter & {t.lower() for t in btags}):
|
||||
continue
|
||||
if ql:
|
||||
hay = " ".join(str(r.get(f) or "") for f in
|
||||
("short_title", "description", "bill_number",
|
||||
"author", "committee")).lower()
|
||||
if ql not in hay:
|
||||
continue
|
||||
if al and al not in str(r.get("author") or "").lower():
|
||||
continue
|
||||
ev = {f: r.get(f) for f in _EVENT_FIELDS}
|
||||
for f in ("hearing_time", "location", "committee", "short_title",
|
||||
"author", "item_number", "supplement", "date", "session"):
|
||||
if ev.get(f) == "":
|
||||
ev[f] = None
|
||||
ev["tags"] = btags if btags else None
|
||||
ev["source"] = r.get("source") or "ASSEMBLY_DAILY_FILE"
|
||||
events.append(ev)
|
||||
return events
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legislators + districts reference data (§3.2 / §4.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_legislators(db_path: str = relational.DEFAULT_DB,
|
||||
q: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Browse legislators (members table) with their committees + domains.
|
||||
|
||||
``domains`` = the domain tags of the committees a member sits on
|
||||
(committee -> its bills' tags). Degrades to an empty list when the
|
||||
correlation lane has no roster (no committee_members).
|
||||
"""
|
||||
conn = relational.connect(db_path, foreign_keys=False)
|
||||
try:
|
||||
members = [dict(r) for r in conn.execute(
|
||||
"SELECT name_norm, full_name, chamber, kind "
|
||||
"FROM members ORDER BY full_name, chamber").fetchall()]
|
||||
rows = conn.execute(
|
||||
"SELECT cm.member, c.name_raw AS committee "
|
||||
"FROM committee_members cm "
|
||||
"JOIN committees c ON c.name_key=cm.committee "
|
||||
"AND c.chamber=cm.chamber AND c.session=cm.session "
|
||||
"ORDER BY cm.member, c.name_raw").fetchall()
|
||||
served: Dict[str, List[str]] = {}
|
||||
for r in rows:
|
||||
served.setdefault(r["member"], []).append(r["committee"])
|
||||
|
||||
if _table_exists(conn, "bill_tags"):
|
||||
cmt_rows = conn.execute(
|
||||
"SELECT c.name_raw AS cn, t.tag FROM bill_committees bc "
|
||||
"JOIN committees c ON c.name_key=bc.committee "
|
||||
"AND c.chamber=bc.chamber AND c.session=bc.session "
|
||||
"JOIN bill_tags t ON t.bill_id=bc.bill_id").fetchall()
|
||||
cmt_tags: Dict[str, set] = {}
|
||||
for r in cmt_rows:
|
||||
cmt_tags.setdefault(r["cn"], set()).add(r["tag"])
|
||||
else:
|
||||
cmt_tags = {}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ql = (q or "").strip().lower()
|
||||
out = []
|
||||
for m in members:
|
||||
slug = m["name_norm"]
|
||||
committees = served.get(slug, [])
|
||||
domains = sorted({t for cn in committees for t in cmt_tags.get(cn, set())})
|
||||
if ql and ql not in str(m.get("full_name") or slug).lower():
|
||||
continue
|
||||
out.append({
|
||||
"legislator_id": slug,
|
||||
"name": m.get("full_name") or slug.title(),
|
||||
"party": None,
|
||||
"chamber": (m.get("chamber") or "").upper(),
|
||||
"district": None,
|
||||
"committees": committees,
|
||||
"domains": domains,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def committee_roster_for(db_path: str = relational.DEFAULT_DB,
|
||||
legislator_ids=None) -> Dict[str, List[str]]:
|
||||
"""Map a watched legislator (by its ``member`` slug) to the committee
|
||||
name_keys that legislator is known to serve on, via the correlation lane's
|
||||
``committee_members`` join.
|
||||
|
||||
Used by the ranking to fire the ``committee-of-watched-legislator`` float
|
||||
path. When the correlation lane found no roster (``committee_members``
|
||||
empty — the honest default), this returns ``{}`` and that path does not
|
||||
fire, so nothing is fabricated.
|
||||
"""
|
||||
conn = relational.connect(db_path, foreign_keys=False)
|
||||
try:
|
||||
if not _table_exists(conn, "committee_members"):
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT cm.member AS m, cm.committee AS ck "
|
||||
"FROM committee_members cm").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
roster: Dict[str, List[str]] = {}
|
||||
for r in rows:
|
||||
m = r["m"]
|
||||
if legislator_ids is None or m in legislator_ids:
|
||||
bucket = roster.setdefault(m, [])
|
||||
if r["ck"] not in bucket:
|
||||
bucket.append(r["ck"])
|
||||
return roster
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Personalization store + watch/topic toggles + a minimal dev auth (spec §5).
|
||||
|
||||
The persistence is a small JSON file (documented in DASHBOARD.md) holding one
|
||||
preferences blob per user_id, matching API_CONTRACT section 3.3. It is
|
||||
intentionally simple and swappable: replacing PrefsStore with a DB-backed store
|
||||
is a local change; the preferences *shape* (and therefore the whole contract)
|
||||
is auth-method-independent, which is the point of Open Decision D1.
|
||||
|
||||
A minimal LOCAL account (LocalAuth) is provided as the D1a default so the
|
||||
dashboard is runnable end-to-end without an IdP. It is explicitly a development
|
||||
placeholder (no password hashing, single-host JSON): swap it for a real auth
|
||||
service in production. Auth is NOT modelled in the data contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preferences store (API_CONTRACT section 3.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PrefsStore:
|
||||
"""File-backed per-user preferences. One blob per user_id.
|
||||
|
||||
On-disk shape is `{"u_001": {...}, ...}` so the store is human-readable and
|
||||
diff-stable. All mutating ops are idempotent: re-adding an existing
|
||||
watch/topic is a no-op, and removing an absent one does not error.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self._data = self._load()
|
||||
|
||||
# -- persistence --------------------------------------------------
|
||||
def _load(self):
|
||||
if not os.path.exists(self.path):
|
||||
return {}
|
||||
try:
|
||||
with open(self.path, encoding="utf-8") as fh:
|
||||
return json.load(fh) or {}
|
||||
except (ValueError, OSError):
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".",
|
||||
exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(self._data, fh, indent=2, sort_keys=True)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
# -- reads --------------------------------------------------------
|
||||
def get(self, user_id):
|
||||
return self._data.get(user_id) or {}
|
||||
|
||||
def has(self, user_id):
|
||||
return user_id in self._data and bool(self._data[user_id])
|
||||
|
||||
def list_users(self):
|
||||
return sorted(k for k in self._data.keys() if self._data[k])
|
||||
|
||||
# -- writes -------------------------------------------------------
|
||||
def put(self, user_id, prefs):
|
||||
merged = self.get(user_id)
|
||||
merged.update(prefs or {})
|
||||
self._clean(merged)
|
||||
self._data[user_id] = merged
|
||||
self._save()
|
||||
return merged
|
||||
|
||||
def add_watch(self, user_id, legislator_id):
|
||||
prefs = self.get(user_id)
|
||||
watch = list(prefs.get("watched_legislators") or [])
|
||||
if legislator_id not in watch:
|
||||
watch.append(legislator_id)
|
||||
return self._set(user_id, "watched_legislators", watch)
|
||||
|
||||
def remove_watch(self, user_id, legislator_id):
|
||||
prefs = self.get(user_id)
|
||||
watch = [x for x in (prefs.get("watched_legislators") or [])
|
||||
if x != legislator_id]
|
||||
return self._set(user_id, "watched_legislators", watch)
|
||||
|
||||
def add_topic(self, user_id, tag):
|
||||
prefs = self.get(user_id)
|
||||
topics = list(prefs.get("opted_in_tags") or [])
|
||||
if tag not in topics:
|
||||
topics.append(tag)
|
||||
return self._set(user_id, "opted_in_tags", topics)
|
||||
|
||||
def remove_topic(self, user_id, tag):
|
||||
prefs = self.get(user_id)
|
||||
topics = [t for t in (prefs.get("opted_in_tags") or []) if t != tag]
|
||||
return self._set(user_id, "opted_in_tags", topics)
|
||||
|
||||
def _set(self, user_id, key, value):
|
||||
prefs = self.get(user_id)
|
||||
prefs[key] = value
|
||||
self._clean(prefs)
|
||||
self._data[user_id] = prefs
|
||||
self._save()
|
||||
return prefs
|
||||
|
||||
@staticmethod
|
||||
def _clean(prefs):
|
||||
for key in ("watched_legislators", "opted_in_tags", "districts"):
|
||||
prefs.setdefault(key, [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal local account (Open Decision D1: D1a default — dev placeholder)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocalAuth:
|
||||
"""A development local-account auth (email + a derived bearer token).
|
||||
|
||||
This is the D1a default so the dashboard runs with no external IdP. It is
|
||||
deliberately NOT production-grade: no password hashing, no rotation, single
|
||||
host. Production should swap in a real auth service or the D1b OAuth path;
|
||||
the preferences shape is identical, so nothing else in the system changes.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self._data = self._load()
|
||||
|
||||
def _load(self):
|
||||
if not os.path.exists(self.path):
|
||||
return {}
|
||||
try:
|
||||
with open(self.path, encoding="utf-8") as fh:
|
||||
return json.load(fh) or {}
|
||||
except (ValueError, OSError):
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".",
|
||||
exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(self._data, fh, indent=2, sort_keys=True)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
@staticmethod
|
||||
def _new_user_id():
|
||||
return "u_" + uuid.uuid4().hex[:8]
|
||||
|
||||
@staticmethod
|
||||
def _token_for(user_id, email):
|
||||
h = hashlib.sha256(("local:" + user_id + ":" + email).encode()).hexdigest()
|
||||
return "dev." + h[:24]
|
||||
|
||||
def register(self, email, user_id=None):
|
||||
email = (email or "").strip().lower()
|
||||
for uid, rec in self._data.items():
|
||||
if rec.get("email") == email:
|
||||
rec["token"] = self._token_for(uid, email)
|
||||
self._save()
|
||||
return {"user_id": uid, "token": rec["token"], "email": email}
|
||||
uid = user_id or self._new_user_id()
|
||||
self._data[uid] = {"email": email, "token": self._token_for(uid, email)}
|
||||
self._save()
|
||||
return {"user_id": uid, "token": self._data[uid]["token"], "email": email}
|
||||
|
||||
def verify_token(self, token):
|
||||
if not token:
|
||||
return None
|
||||
for uid, rec in self._data.items():
|
||||
t = rec.get("token") or ""
|
||||
if token == t or token == t[4:] or token == uid:
|
||||
return uid
|
||||
return None
|
||||
|
||||
def user_by_id(self, user_id):
|
||||
return self._data.get(user_id)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Pure ranking core for the legislative dashboard (spec §5, DESIGN_DASHBOARD §4).
|
||||
|
||||
This module is deliberately **I/O-free**: it takes plain ``dict``-shaped bill
|
||||
rows (``scraper.schema.BillAction`` fields, optionally with a ``tags`` list) and
|
||||
returns them in float-to-top order. Keeping the ranking pure is what lets the
|
||||
rank logic be unit-tested without a database, and is the heart of §5.
|
||||
|
||||
The rule (DESIGN_DASHBOARD §4, verbatim intent)::
|
||||
|
||||
A bill FLOATS TO THE TOP iff
|
||||
author ∩ watched_legislators ≠ ∅ (author path)
|
||||
OR bill.tags ∩ opted_in_tags ≠ ∅ (tag path)
|
||||
OR (committee-of-watched-legistrator path) # committee -> members join
|
||||
|
||||
Scoring (design default, tunable — Open Decision D3)::
|
||||
|
||||
score = 3·(author is watched)
|
||||
+ 2·(author sits-in a watched legislator's committee)
|
||||
+ 2·( |tags ∩ opted_in_tags| )
|
||||
+ 1·(chamber == user's home chamber hint)
|
||||
|
||||
Band membership = ``score > 0``. Intra-band order = descending score, then
|
||||
``hearing_sort`` ascending (a future, earlier hearing floats ahead of a later
|
||||
one with the same score). The general band keeps source order.
|
||||
|
||||
Nothing here is "hidden": floated bills are *promoted* (a ``Relevant to you``
|
||||
marker / a 3px meaning bar), never removed — so a bill appears in both the
|
||||
personal band and the full queue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Sequence, Set
|
||||
|
||||
from . import PERSONAL_BAND_N
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hearing_sort (API_CONTRACT §1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# "Upon Call of the Chair" / "Call of the Chair" sort LAST; a missing/unknown
|
||||
# time also sinks to the end of its day (design: time TBD note). A large
|
||||
# sentinel keeps them last without inventing a wall-clock value.
|
||||
_SORT_LAST = 24 * 60 * 60 + 60000 # > any HH:MM, so call-of-chair always sinks
|
||||
|
||||
_CALL_OF_CHAIR_RE = re.compile(r"call of the chair", re.I)
|
||||
_AMPM_RE = re.compile(r"^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$", re.I)
|
||||
# A bare clock like "9:30" (no am/pm) — treat as 24h HH:MM.
|
||||
_HHMM_RE = re.compile(r"^(\d{1,2}):([0-5]?\d)$")
|
||||
|
||||
|
||||
def hearing_sort(hearing_time: Optional[str]) -> int:
|
||||
"""Derive a sortable integer for ``hearing_time``.
|
||||
|
||||
API_CONTRACT §1:
|
||||
|
||||
* ``"9:30am"`` / ``"10 minutes prior to Session"``-style wall times ->
|
||||
seconds-from-midnight.
|
||||
* ``"Upon Call of the Chair"`` / ``"Call of the Chair"`` -> **max** (last).
|
||||
* ``None`` / unparseable -> **max** (last), with the row still carrying
|
||||
``hearing_time: None`` so the UI can render a "time TBD" note.
|
||||
|
||||
Returns an int suitable for ascending sort within a day.
|
||||
"""
|
||||
if not hearing_time:
|
||||
return _SORT_LAST
|
||||
s = str(hearing_time).strip()
|
||||
if not s:
|
||||
return _SORT_LAST
|
||||
if _CALL_OF_CHAIR_RE.search(s):
|
||||
return _SORT_LAST
|
||||
m = _AMPM_RE.match(s)
|
||||
if m:
|
||||
hour = int(m.group(1))
|
||||
minute = int(m.group(2) or 0)
|
||||
period = m.group(3).lower()
|
||||
if period == "pm" and hour != 12:
|
||||
hour += 12
|
||||
if period == "am" and hour == 12: # 12am == midnight
|
||||
hour = 0
|
||||
return hour * 3600 + minute * 60
|
||||
m = _HHMM_RE.match(s)
|
||||
if m:
|
||||
return int(m.group(1)) * 3600 + int(m.group(2)) * 60
|
||||
# Unparseable (e.g. "10 minutes prior to Session") -> sinks last; that is
|
||||
# the designed "time TBD" behaviour, not a crash.
|
||||
return _SORT_LAST
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relevance scoring (DESIGN_DASHBOARD §4, weights per Open Decision D3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Weights:
|
||||
"""Scoring weights. Defaults match DESIGN_DASHBOARD §4.
|
||||
|
||||
These are a *design default*, not frozen — Open Decision D3 flags them as
|
||||
tunable. The :class:`Weights` object is kept explicit (rather than module
|
||||
globals) so tests and the API can pass alternative weights without global
|
||||
mutation.
|
||||
"""
|
||||
|
||||
def __init__(self, author_watched: int = 3, committee_watched: int = 2,
|
||||
tag_watched: int = 2, home_chamber: int = 1):
|
||||
self.author_watched = author_watched
|
||||
self.committee_watched = committee_watched
|
||||
self.tag_watched = tag_watched
|
||||
self.home_chamber = home_chamber
|
||||
|
||||
|
||||
DEFAULT_WEIGHTS = Weights()
|
||||
|
||||
|
||||
def normalise_author(author: Optional[str]) -> str:
|
||||
"""Reduce a bill ``author`` string ("Schiavo et al.", "Macedo") to the
|
||||
primary legislator's slug, matching the member slugs the resolve/prefs
|
||||
layer keys on. Mirrors ``relational.slug`` / author-stem stripping."""
|
||||
if not author:
|
||||
return ""
|
||||
s = re.sub(r"\s+et al\.$", "", author.strip())
|
||||
s = re.sub(r"\s+et al\s+", " ", s, flags=re.I)
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s.lower()
|
||||
|
||||
|
||||
def _slug(name: Optional[str]) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") or "unknown"
|
||||
|
||||
|
||||
def _match_tags(bill_tags: Sequence[str],
|
||||
opted: Set[str]) -> List[str]:
|
||||
"""Opted-in tags that actually appear on this bill (intersection, stable
|
||||
order = opted-in order), so the "Why is this here?" list is deterministic."""
|
||||
if not opted:
|
||||
return []
|
||||
present = {str(t).strip().lower() for t in (bill_tags or [])}
|
||||
return [t for t in opted if str(t).strip().lower() in present]
|
||||
|
||||
|
||||
def score_bill(bill: Dict,
|
||||
watched_authors: Set[str],
|
||||
watched_committees: Set[str],
|
||||
opted_in_tags: Set[str],
|
||||
home_chamber: Optional[str] = None,
|
||||
weights: Optional[Weights] = None) -> Dict:
|
||||
"""Compute a relevance ``{score, matched[]}`` for one bill against a user's
|
||||
preferences.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bill: a dict-like with at least ``bill_id``, ``author``, ``committee``,
|
||||
``chamber_origin`` and optionally ``tags`` (list of tag strings).
|
||||
watched_authors: lower-cased primary-author slugs the user watches
|
||||
(``author ∩ watched_legislators`` path).
|
||||
watched_committees: slugs of committees the user watches (committee-of-
|
||||
watched-legislator path; ``∅`` when the correlation lane found no
|
||||
roster — never fabricated).
|
||||
opted_in_tags: tag strings the user opted into (``tags ∩ opted_in_tags``).
|
||||
home_chamber: optional chamber hint ("assembly"/"senate") for the +1 term.
|
||||
weights: scoring weights (defaults to DESIGN_DASHBOARD §4).
|
||||
|
||||
Returns
|
||||
-------
|
||||
A ``{"score": int, "matched": [{"type", "value"}, ...]}`` dict, matching the
|
||||
``Bill.relevance`` shape in API_CONTRACT §1. ``matched`` powers the
|
||||
"Why is this here?" transparency toggle. A single rule suffices to float
|
||||
(OR); stronger matches score higher.
|
||||
"""
|
||||
w = weights or DEFAULT_WEIGHTS
|
||||
matched: List[Dict[str, str]] = []
|
||||
score = 0
|
||||
|
||||
# --- author path ------------------------------------------------------
|
||||
author = bill.get("author")
|
||||
norm_author = normalise_author(author)
|
||||
if norm_author and norm_author in {a.lower() for a in watched_authors}:
|
||||
score += w.author_watched
|
||||
matched.append({
|
||||
"type": "watched-legislator",
|
||||
"value": "Author is a watched legislator"
|
||||
+ ("" if not author else " — " + str(author)),
|
||||
})
|
||||
|
||||
# --- committee-of-watched-legislator path ----------------------------
|
||||
# The bill sits in a committee the user's watched legislators chair/serve.
|
||||
# `bill.committee` is the daily-file committee name; watched_committees is
|
||||
# the slug set of the watched set's committees. (Degrades to a no-op when
|
||||
# the correlation lane found no roster — honest, never fabricated.)
|
||||
committee = bill.get("committee")
|
||||
if committee and _slug(committee) in {c.lower() for c in watched_committees}:
|
||||
score += w.committee_watched
|
||||
matched.append({
|
||||
"type": "committee-of-watched-legislator",
|
||||
"value": "Sits in a committee your watched legislator chair/serves "
|
||||
+ ("— " + str(committee) if committee else ""),
|
||||
})
|
||||
|
||||
# --- tag path ---------------------------------------------------------
|
||||
tag_hits = _match_tags(bill.get("tags") or [],
|
||||
{t.lower() for t in opted_in_tags})
|
||||
if tag_hits:
|
||||
score += w.tag_watched * len(tag_hits)
|
||||
for t in tag_hits:
|
||||
matched.append({"type": "tag", "value": "Topic: " + str(t)})
|
||||
|
||||
# --- home-chamber hint (optional, +1) --------------------------------
|
||||
if home_chamber and bill.get("chamber_origin"):
|
||||
if str(bill["chamber_origin"]).lower() == str(home_chamber).lower():
|
||||
score += w.home_chamber
|
||||
matched.append({
|
||||
"type": "home-chamber",
|
||||
"value": "Matches your home chamber ("
|
||||
+ str(bill["chamber_origin"]) + ")",
|
||||
})
|
||||
|
||||
return {"score": int(score), "matched": matched}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bill normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Fields the UI consumes from a BillAction row, in API_CONTRACT §1 display order.
|
||||
_BILL_FIELDS = (
|
||||
"bill_id", "bill_number", "chamber_origin", "author", "short_title",
|
||||
"description", "committee", "hearing_time", "location", "date",
|
||||
"session", "item_number", "supplement", "source", "tags",
|
||||
)
|
||||
|
||||
|
||||
def normalise_bill(row: Dict, tags: Optional[Sequence[str]] = None) -> Dict:
|
||||
"""Project a raw DB row onto the API_CONTRACT §1 ``Bill`` shape.
|
||||
|
||||
* Keeps the exact field names the contract freezes.
|
||||
* Adds the derived ``hearing_sort`` (backend-owned, §1).
|
||||
* Carries ``tags`` from the LLM lane (may be ``[]``/``None`` -> no chips,
|
||||
ranking degrades to legislator-match only, per §6.6).
|
||||
* Nulls are preserved (the UI renders TBD, never crashes).
|
||||
"""
|
||||
out: Dict = {}
|
||||
for f in _BILL_FIELDS:
|
||||
if f == "tags":
|
||||
out["tags"] = None if tags is None else list(tags)
|
||||
else:
|
||||
val = row.get(f)
|
||||
out[f] = None if val == "" else val
|
||||
out["hearing_sort"] = hearing_sort(row.get("hearing_time"))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Float-to-top ordering (DESIGN_DASHBOARD §4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_floated(bill: Dict) -> bool:
|
||||
"""True if the bill carries a re: positive score (i.e. it floats).
|
||||
|
||||
``relevance`` is only populated for logged-in requests (§1); anon bills have
|
||||
no ``relevance`` key.
|
||||
"""
|
||||
rel = bill.get("relevance")
|
||||
return bool(rel and int(rel.get("score", 0)) > 0)
|
||||
|
||||
|
||||
def float_to_top(bills: Sequence[Dict],
|
||||
personal_band_n: int = PERSONAL_BAND_N) -> List[Dict]:
|
||||
"""Stably re-sort ``bills`` so floated bills (``relevance.score > 0``) come
|
||||
first, ordered by ``(score desc, hearing_sort asc)``; non-floated bills keep
|
||||
their existing relative order.
|
||||
|
||||
This is the "promote, never hide" step of DESIGN_DASHBOARD §4: every floated
|
||||
bill also remains visible in the full queue (marked), only its *position*
|
||||
changes. Idempotent: running it twice is a no-op.
|
||||
"""
|
||||
def key(bill: Dict) -> tuple:
|
||||
rel = bill.get("relevance") or {}
|
||||
score = int(rel.get("score", 0))
|
||||
# -score so higher scores sort earlier; hearing earlier-in-day sorts
|
||||
# earlier; tie-break on bill_id for total determinism.
|
||||
return (-score,
|
||||
int(bill.get("hearing_sort") or _SORT_LAST),
|
||||
str(bill.get("bill_id") or ""))
|
||||
|
||||
# Stable sort (Python's sort is stable; the bill_id tie-break makes it
|
||||
# total anyway). personal_band_n is accepted for signature parity with the
|
||||
# band helpers but does NOT truncate the full queue.
|
||||
_ = personal_band_n
|
||||
ordered = sorted(bills, key=key)
|
||||
return ordered
|
||||
|
||||
|
||||
def personal_band(bills: Sequence[Dict],
|
||||
n: int = PERSONAL_BAND_N) -> List[Dict]:
|
||||
"""The first ``n`` floated bills (the "Relevant to you" band, top N=10).
|
||||
|
||||
Derived by "relevance.score > 0, then hearing_sort", per API_CONTRACT §1 —
|
||||
i.e. the head of a :func:`float_to_top` ordering. Returns ``[]`` when nothing
|
||||
is floated (the UI shows the setup / empty state, not a dead band).
|
||||
"""
|
||||
band = [b for b in float_to_top(bills) if is_floated(b)]
|
||||
return band[:n]
|
||||
|
||||
|
||||
def full_queue(bills: Sequence[Dict]) -> List[Dict]:
|
||||
"""The complete queue, sorted the same way as :func:`float_to_top` but
|
||||
untruncated — every floated bill is marked and every other bill follows."""
|
||||
return float_to_top(bills)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""ZIP/address -> district -> legislators resolution (spec §5, DESIGN §8, D2).
|
||||
|
||||
Open Decision D2 (inherited from the UX lane) asks which ZIP-to-district data
|
||||
source the dashboard owns. The authoritative source is the Legislature's
|
||||
full-address tool / a county voter file or data.ca.gov boundary datasets; that
|
||||
ETL is the correlation lane's job. This module provides a runnable demonstration
|
||||
resolver so the ZIP->legislator flow is real and testable:
|
||||
|
||||
* a small, documented ZIP sample map (see DEMO_ZIPS) covering a few districts
|
||||
of the current 2025-2026 session;
|
||||
* a ZIP fast path (0 / 1 / several districts) with the confidence semantics the
|
||||
design freezes (single / ambiguous / zero / out-of-state);
|
||||
* a manual fallback (pick a district by hand) for the edge cases the design
|
||||
requires (P.O. boxes, cross-border ZIPs, post-redistricting gaps).
|
||||
|
||||
Real-world fact the design grounds this in: a ZIP alone does NOT reliably
|
||||
resolve a CA district (a single ZIP can straddle two districts). Fast-path
|
||||
ambiguity therefore proposes candidates; the user commits (never auto-watched).
|
||||
A real deployment swaps DEMO_ZIPS for the data.ca.gov boundary join, which is
|
||||
documented in DASHBOARD.md.
|
||||
|
||||
Resolve never persists: it proposes (API_CONTRACT section 3.1). Persisting a
|
||||
watch is the user's explicit act via /prefs/watch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from . import data
|
||||
|
||||
# DEMO sample: ZIP -> the (current) district(s) it maps to, each with its
|
||||
# assembly/senate member. Explicitly a placeholder for the authoritative
|
||||
# boundary join (D2). Legislators reference the session's real member slugs.
|
||||
DEMO_ZIPS = {
|
||||
"90210": [{"district_id": "A-14", "type": "ASSEMBLY", "number": 14,
|
||||
"assembly_member": {"legislator_id": "schiavo",
|
||||
"name": "M. Schiavo", "party": "D"}}],
|
||||
"94107": [{"district_id": "A-11", "type": "ASSEMBLY", "number": 11,
|
||||
"assembly_member": {"legislator_id": "macedo",
|
||||
"name": "K. Macedo", "party": "D"}}],
|
||||
"90012": [
|
||||
# A ZIP straddling two districts -> AMBIGUOUS (the design's example).
|
||||
{"district_id": "S-08", "type": "SENATE", "number": 8,
|
||||
"senate_member": {"legislator_id": "chen",
|
||||
"name": "W. Chen", "party": "R"}},
|
||||
{"district_id": "A-09", "type": "ASSEMBLY", "number": 9,
|
||||
"assembly_member": {"legislator_id": "ortega",
|
||||
"name": "A. Ortega", "party": "D"}},
|
||||
],
|
||||
"92101": [{"district_id": "A-32", "type": "ASSEMBLY", "number": 32,
|
||||
"assembly_member": {"legislator_id": "bryan",
|
||||
"name": "B. Bryan", "party": "D"}}],
|
||||
"99501": [{"district_id": "A-55", "type": "ASSEMBLY", "number": 55,
|
||||
"assembly_member": {"legislator_id": "jackson",
|
||||
"name": "H. Jackson", "party": "R"}}],
|
||||
}
|
||||
|
||||
# 5-digit ZIP prefixes that are obviously not California, so the out-of-state
|
||||
# confidence branch is demonstrable.
|
||||
_OUT_OF_STATE_PREFIX = ("990", "999")
|
||||
|
||||
|
||||
def _to_resolution(mode: str, districts: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
suggested_watch = []
|
||||
for d in districts:
|
||||
for member in (d.get("assembly_member"), d.get("senate_member")):
|
||||
if member and member.get("legislator_id"):
|
||||
suggested_watch.append(member["legislator_id"])
|
||||
return {
|
||||
"resolved_from": mode,
|
||||
"confidence": "single" if len(districts) == 1
|
||||
else ("ambiguous" if districts else "zero"),
|
||||
"districts": [dict(d) for d in districts],
|
||||
"suggested_topics": _suggested_topics(districts),
|
||||
"suggested_watch": suggested_watch,
|
||||
}
|
||||
|
||||
|
||||
def _manual_district(district: str) -> Dict[str, Any]:
|
||||
d = district.strip()
|
||||
typ = d[0].upper() if d else "A"
|
||||
num = d[1:]
|
||||
type_ = "SENATE" if typ == "S" else "ASSEMBLY"
|
||||
out: Dict[str, Any] = {
|
||||
"district_id": d,
|
||||
"type": type_,
|
||||
"number": int(num) if num.isdigit() else None,
|
||||
}
|
||||
member_key = "senate_member" if type_ == "SENATE" else "assembly_member"
|
||||
out[member_key] = {}
|
||||
for entry in DEMO_ZIPS.values():
|
||||
for cand in entry:
|
||||
if cand.get("district_id") == d and cand.get(member_key):
|
||||
out[member_key] = dict(cand[member_key])
|
||||
return out
|
||||
|
||||
|
||||
def _suggested_topics(districts: Sequence[Dict[str, Any]]) -> List[str]:
|
||||
topics: List[str] = []
|
||||
seen = set()
|
||||
for d in districts:
|
||||
for member in (d.get("assembly_member"), d.get("senate_member")):
|
||||
if not member:
|
||||
continue
|
||||
lid = member.get("legislator_id")
|
||||
for lg in data.load_legislators():
|
||||
if lg.get("legislator_id") == lid:
|
||||
for x in lg.get("domains") or []:
|
||||
if x not in seen:
|
||||
seen.add(x)
|
||||
topics.append(x)
|
||||
return topics
|
||||
|
||||
|
||||
def resolve(zip: Optional[str] = None, address: Optional[str] = None,
|
||||
district: Optional[str] = None) -> Dict[str, Any]:
|
||||
# Manual pick: no ZIP ambiguity at all.
|
||||
if district and not zip and not address:
|
||||
d = _manual_district(district)
|
||||
return _to_resolution("manual", [d])
|
||||
key = None
|
||||
mode = "zip"
|
||||
if address:
|
||||
a = address.strip()
|
||||
key = a[-5:] if len(a) >= 5 else a
|
||||
mode = "address"
|
||||
elif zip is not None:
|
||||
key = str(zip).strip()
|
||||
else:
|
||||
return {"resolved_from": "zip", "confidence": "zero",
|
||||
"districts": [], "suggested_topics": [],
|
||||
"suggested_watch": []}
|
||||
entry = DEMO_ZIPS.get(key)
|
||||
if entry is None:
|
||||
confidence = "out-of-state" if key[:3] in _OUT_OF_STATE_PREFIX \
|
||||
else "zero"
|
||||
return {"resolved_from": mode, "confidence": confidence,
|
||||
"districts": [], "suggested_topics": [],
|
||||
"suggested_watch": []}
|
||||
return _to_resolution(mode, entry)
|
||||
@@ -0,0 +1,186 @@
|
||||
# noqa: E501
|
||||
"""Stdlib HTTP server for the dashboard (spec §5). No framework.
|
||||
|
||||
Exposes the API_CONTRACT endpoints over http.server + a tiny static adapter
|
||||
for the frontend (scraper/dashboard/static/). A thin layer over
|
||||
scraper.dashboard.api: parse HTTP line + query + JSON body, build an
|
||||
api.Request, call api.run, write (status, body) back as JSON.
|
||||
|
||||
Run: python -m scraper.dashboard.server
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from . import api
|
||||
from .api import Request, Services
|
||||
|
||||
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"static")
|
||||
|
||||
_CONTENT_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".png": "image/png",
|
||||
}
|
||||
|
||||
|
||||
class DashboardHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
return self._dispatch("GET")
|
||||
|
||||
def do_POST(self):
|
||||
return self._dispatch("POST")
|
||||
|
||||
def do_PUT(self):
|
||||
return self._dispatch("PUT")
|
||||
|
||||
def do_DELETE(self):
|
||||
return self._dispatch("DELETE")
|
||||
|
||||
def _dispatch(self, method):
|
||||
parsed = urlparse(self.path)
|
||||
if not parsed.path.startswith("/api"):
|
||||
return self._serve_static(parsed.path)
|
||||
try:
|
||||
query = parse_qs(parsed.query)
|
||||
flat = {}
|
||||
for k, v in query.items():
|
||||
flat[k] = v[0] if len(v) == 1 else v
|
||||
body = {}
|
||||
if method in ("POST", "PUT"):
|
||||
body = self._read_json_body()
|
||||
token = self._bearer()
|
||||
req = Request(
|
||||
method=method,
|
||||
path=parsed.path,
|
||||
query=flat,
|
||||
body=body,
|
||||
token=token,
|
||||
services=self.services,
|
||||
user_id=None,
|
||||
)
|
||||
status, payload = api.run(req)
|
||||
except Exception as exc:
|
||||
status, payload = 500, {
|
||||
"error": {"code": "internal_error",
|
||||
"message": str(exc)}}
|
||||
return self._write_json(status, payload)
|
||||
|
||||
def _bearer(self):
|
||||
auth = self.headers.get("Authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
return None
|
||||
|
||||
def _read_json_body(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if length <= 0:
|
||||
return {}
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _serve_static(self, path):
|
||||
rel = path.lstrip("/") or "index.html"
|
||||
if ".." in rel or rel.startswith("/"):
|
||||
return self._write_raw(404, b"not found", "text/plain")
|
||||
fp = os.path.join(STATIC_DIR, rel)
|
||||
if os.path.isfile(fp):
|
||||
ctype = _CONTENT_TYPES.get(
|
||||
os.path.splitext(fp)[1], "application/octet-stream")
|
||||
with open(fp, "rb") as fh:
|
||||
body = fh.read()
|
||||
return self._write_raw(200, body, ctype)
|
||||
root = os.path.join(STATIC_DIR, "index.html")
|
||||
if os.path.isfile(root):
|
||||
with open(root, "rb") as fh:
|
||||
body = fh.read()
|
||||
return self._write_raw(200, body,
|
||||
"text/html; charset=utf-8")
|
||||
return self._write_raw(404, b"not found", "text/plain")
|
||||
|
||||
def _write_json(self, status, payload):
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
return self._write_raw(status, body,
|
||||
"application/json; charset=utf-8")
|
||||
|
||||
def _write_raw(self, status, body, ctype):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
if status != 204:
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def make_handler(services, log_on=False):
|
||||
cls = type("BoundHandler", (DashboardHandler,),
|
||||
{"services": services})
|
||||
if log_on:
|
||||
cls.log_message = BaseHTTPRequestHandler.log_message
|
||||
return cls
|
||||
|
||||
|
||||
def serve(host="127.0.0.1", port=8000,
|
||||
db_path=None, prefs_path="out/prefs.json",
|
||||
auth_path="out/auth.json", serve_static=True,
|
||||
log_on=False):
|
||||
services = Services.default(db_path=db_path,
|
||||
prefs_path=prefs_path,
|
||||
auth_path=auth_path)
|
||||
handler = make_handler(services, log_on=log_on)
|
||||
srv = ThreadingHTTPServer((host, port), handler)
|
||||
sys.stderr.write(
|
||||
"dashboard: http://%s:%d/ (static %s)\n"
|
||||
% (host, port, "on" if serve_static else "off"))
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
srv.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(
|
||||
prog="scraper.dashboard",
|
||||
description="Serve the legislative dashboard (API + static UI).")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8000)
|
||||
p.add_argument("--db", default=None,
|
||||
help="SQLite path (default: db/daily_file.sqlite)")
|
||||
p.add_argument("--prefs", default="out/prefs.json",
|
||||
help="preferences JSON file")
|
||||
p.add_argument("--auth", default="out/auth.json",
|
||||
help="local-auth JSON file")
|
||||
p.add_argument("--no-static", dest="static", action="store_false",
|
||||
help="serve API only, no static frontend")
|
||||
p.add_argument("--log", dest="log", action="store_true",
|
||||
help="log HTTP requests to stderr")
|
||||
args = p.parse_args(argv)
|
||||
return serve(host=args.host, port=args.port,
|
||||
db_path=args.db, prefs_path=args.prefs,
|
||||
auth_path=args.auth, serve_static=args.static,
|
||||
log_on=args.log)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,657 @@
|
||||
/* ============================================================================
|
||||
Legislative Dashboard — frontend (spec §5, DESIGN §3/§5/§10).
|
||||
Vanilla JS + fetch against the stdlib API. Two personas on one surface:
|
||||
anonymous (overview) and logged-in (personal band + watch/opt-in). No
|
||||
frameworks, no build step.
|
||||
========================================================================== */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var API = "/api/v1";
|
||||
var BAND_N = 10;
|
||||
var state = {
|
||||
token: localStorage.getItem("dash_token") || null,
|
||||
user: null,
|
||||
prefs: null,
|
||||
filters: { upcoming: true, chamber: "", q: "", tags: [] },
|
||||
personal: false,
|
||||
showReason: false,
|
||||
allItems: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
/* ---------- tiny fetch client ---------- */
|
||||
function apiFetch(path, opts) {
|
||||
opts = opts || {};
|
||||
var headers = { "Content-Type": "application/json" };
|
||||
if (state.token) headers["Authorization"] = "Bearer " + state.token;
|
||||
return fetch(API + path, {
|
||||
method: opts.method || "GET",
|
||||
headers: headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
return { status: r.status, body: j };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
/* ---------- render helpers ---------- */
|
||||
function chip(chamber) {
|
||||
var c = (chamber || "ASSEMBLY").toLowerCase();
|
||||
var label = c === "senate" ? "Senate" : "Assembly";
|
||||
return '<span class="chip ' + (c === "senate" ? "sen" : "asm") + '">'
|
||||
+ label + "</span>";
|
||||
}
|
||||
|
||||
function legMatches(ev) {
|
||||
var m = (ev.relevance && ev.relevance.matched) || [];
|
||||
return m.some(function (x) { return x.type !== "tag"; });
|
||||
}
|
||||
function tagMatches(ev) {
|
||||
var m = (ev.relevance && ev.relevance.matched) || [];
|
||||
return m.some(function (x) { return x.type === "tag"; });
|
||||
}
|
||||
function floatClass(ev) {
|
||||
var l = legMatches(ev), t = tagMatches(ev);
|
||||
if (t && l) return "both";
|
||||
if (t) return "tag-match";
|
||||
if (l) return "leg-match";
|
||||
return "";
|
||||
}
|
||||
|
||||
function rowHTML(ev, opts) {
|
||||
opts = opts || {};
|
||||
var rel = ev.relevance && ev.relevance.score > 0;
|
||||
var cls = "row";
|
||||
if (rel) cls += " relevant " + floatClass(ev);
|
||||
var time = ev.hearing_time || '<span class="meta">time TBD</span>';
|
||||
var meta = [time, ev.committee, ev.location, ev.session]
|
||||
.filter(Boolean).join(" · ");
|
||||
var tags = (ev.tags || []).slice(0, 6)
|
||||
.map(function (tg) {
|
||||
var on = state.prefs &&
|
||||
(state.prefs.opted_in_tags || []).indexOf(tg) >= 0;
|
||||
return '<span class="tag ' +
|
||||
(on ? 'on"' : '"') + ">" + esc(tg) + "</span>";
|
||||
}).join("");
|
||||
var marker = rel
|
||||
? '<span class="chip marker" title="Floated: relevant to you">'
|
||||
+ "Relevant to you</span>"
|
||||
: "";
|
||||
var reasons = "";
|
||||
if (rel) {
|
||||
var parts = (ev.relevance.matched || []).map(function (x) {
|
||||
return esc(x.value);
|
||||
});
|
||||
reasons = '<div class="reason' +
|
||||
(state.showReason ? " show" : "") + '">'
|
||||
+ "Floated: " + parts.join("; ") + "</div>";
|
||||
}
|
||||
var url = "https://leginfo.legislature.ca.gov/bill/" +
|
||||
(ev.session || "2025-2026") + "/AB" + billNum(ev);
|
||||
return '<div class="' + cls + '" data-bill="' +
|
||||
esc(ev.bill_id) + '">'
|
||||
+ '<span class="num">' + chip(ev.chamber_origin) +
|
||||
'<br>' + esc(ev.bill_number) + "</span>"
|
||||
+ '<div class="main"><span class="title">'
|
||||
+ esc(ev.short_title || ev.bill_number) + "</span>"
|
||||
+ "<div class='meta'>" + esc(meta) + "</div>"
|
||||
+ "<a href='" + url + "' target='_blank'"
|
||||
+ " rel='noopener'>View →</a> " + marker
|
||||
+ tags
|
||||
+ reasons
|
||||
+ '</div>'
|
||||
+ '<div class="actions">'
|
||||
+ (state.prefs
|
||||
? '<button class="why-btn" type="button">Why▸</button>'
|
||||
: "")
|
||||
+ "</div>"
|
||||
+ "</div>";
|
||||
}
|
||||
|
||||
function billNum(ev) {
|
||||
var m = (ev.bill_number || "").match(/(\d+)/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/* ---------- band rendering ---------- */
|
||||
function groupByDay(items) {
|
||||
var groups = {};
|
||||
items.forEach(function (it) {
|
||||
var key = it.date || "TBD";
|
||||
if (!groups[key]) groups[key] = { items: [], key: key };
|
||||
groups[key].items.push(it);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderGroups(container, items) {
|
||||
var byDate = groupByDay(items);
|
||||
var html = "";
|
||||
Object.keys(byDate).sort().forEach(function (d) {
|
||||
var g = byDate[d];
|
||||
var label = g.key === "TBD" ? "Date TBD"
|
||||
: niceDate(g.key) + " · " + g.items.length +
|
||||
" item" + (g.items.length > 1 ? "s" : "");
|
||||
html += '<div class="day-heading"><b>' + esc(label) +
|
||||
"</b></div>";
|
||||
g.items.forEach(function (ev) {
|
||||
html += rowHTML(ev);
|
||||
});
|
||||
});
|
||||
container.innerHTML = html;
|
||||
wireRows(container);
|
||||
}
|
||||
|
||||
function niceDate(iso) {
|
||||
try {
|
||||
var d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: "short", month: "short", day: "numeric",
|
||||
year: "numeric" });
|
||||
} catch (e) { return iso; }
|
||||
}
|
||||
|
||||
/* ---------- empty states (DESIGN §6) ---------- */
|
||||
function showEmpty(kind, container) {
|
||||
var el = el(container);
|
||||
var h = "";
|
||||
if (kind === "no-next") {
|
||||
h = "<h3>No hearings scheduled</h3>" +
|
||||
"<p>No upcoming daily-file items in this window.</p>" +
|
||||
"<button type='button' class='escape' id='see-past'>" +
|
||||
"See recent actions instead</button>";
|
||||
} else if (kind === "filter") {
|
||||
h = "<h3>No upcoming items match your filters</h3>" +
|
||||
"<p><button type='button' class='escape' id='clear'>" +
|
||||
"Clear filters</button> · " +
|
||||
"<button type='button' class='escape' id='loosen'>" +
|
||||
"Loosen to all topics</button> · " +
|
||||
"<button type='button' class='escape' id='widen'>" +
|
||||
"Widen date window</button></p>";
|
||||
} else if (kind === "setup") {
|
||||
el.innerHTML = "";
|
||||
el.hidden = false;
|
||||
el.parentElement.querySelector("#band-setup").hidden = false;
|
||||
return;
|
||||
} else if (kind === "no-tags") {
|
||||
h = "<h3>Topic analysis is filling in</h3>" +
|
||||
"<p>Tag chips appear as the topic service warms. " +
|
||||
"You can still watch legislators above.</p>";
|
||||
}
|
||||
el.innerHTML = h;
|
||||
el.hidden = false;
|
||||
wire(el, kind);
|
||||
}
|
||||
|
||||
function clearEmpty(container) {
|
||||
var e = el(container);
|
||||
e.hidden = true; e.innerHTML = "";
|
||||
}
|
||||
|
||||
/* ---------- load + render ---------- */
|
||||
function load() {
|
||||
var q = new URLSearchParams();
|
||||
if (state.filters.upcoming) q.set("upcoming", "1");
|
||||
if (state.filters.chamber) q.set("chamber", state.filters.chamber);
|
||||
if (state.filters.q) q.set("q", state.filters.q);
|
||||
if (state.filters.tags.length)
|
||||
q.set("tags", state.filters.tags.join(","));
|
||||
if (state.personal && state.token) q.set("personal", "1");
|
||||
q.set("full", "1");
|
||||
apiFetch("/bills?" + q.toString()).then(function (r) {
|
||||
if (r.status !== 200) { return; }
|
||||
render(r.body);
|
||||
});
|
||||
}
|
||||
|
||||
function render(env) {
|
||||
// freshness / context band
|
||||
el("freshness-updated").textContent =
|
||||
"Updated " + (env.as_of || "").slice(0, 10);
|
||||
el("freshness-session").textContent = "Session " + (env.session || "");
|
||||
var c = env.chamber_availability || {};
|
||||
el("freshness-chamber").textContent =
|
||||
c.SENATE === "available"
|
||||
? "Senate available"
|
||||
: "Senate unavailable today";
|
||||
el("freshness-chamber").style.color =
|
||||
c.SENATE === "available" ? "var(--muted)" : "var(--warn)";
|
||||
|
||||
var items = env.items || [];
|
||||
state.allItems = items;
|
||||
var band = items.filter(function (i) {
|
||||
return i.relevance && i.relevance.score > 0;
|
||||
});
|
||||
var bandFull = band;
|
||||
var shown = bandFull.slice(0, BAND_N);
|
||||
|
||||
// personal band
|
||||
var bandCount = el("band-count");
|
||||
bandCount.textContent = bandFull.length
|
||||
? bandFull.length + (bandFull.length > BAND_N
|
||||
? " (showing " + shown.length + ")" : "") : "";
|
||||
var list = el("band-list");
|
||||
var setup = el("band-setup");
|
||||
if (!state.prefs || (state.prefs.watched_legislators || []).length
|
||||
=== 0 && (state.prefs.opted_in_tags || []).length === 0) {
|
||||
list.innerHTML = "";
|
||||
setup.hidden = false;
|
||||
} else {
|
||||
setup.hidden = true;
|
||||
if (!shown.length) {
|
||||
list.innerHTML = "<p class='meta'>No bills match your " +
|
||||
"watches/topics yet. Add more on the right, or " +
|
||||
"<button type='button' class='escape' id='clear-watch'>" +
|
||||
"Clear filters</button>.</p>";
|
||||
} else {
|
||||
renderGroups(list, shown);
|
||||
}
|
||||
var btn = el("btn-show-all");
|
||||
btn.hidden = bandFull.length <= BAND_N;
|
||||
}
|
||||
|
||||
// all band
|
||||
var allCount = el("all-count");
|
||||
allCount.textContent = items.length +
|
||||
" upcoming" + (items.length === 1 ? " item" : " items");
|
||||
var allList = el("all-list");
|
||||
var allEmpty = el("all-empty");
|
||||
if (!items.length) {
|
||||
allList.innerHTML = "";
|
||||
showEmpty("no-next", "all-empty");
|
||||
} else {
|
||||
clearEmpty("all-empty");
|
||||
renderGroups(allList, items);
|
||||
}
|
||||
|
||||
// aside teaser
|
||||
renderTeaser();
|
||||
loadTags();
|
||||
}
|
||||
|
||||
function renderTeaser() {
|
||||
var ul = el("topics-teaser");
|
||||
ul.innerHTML = "";
|
||||
(state.tags || []).slice(0, 6).forEach(function (t) {
|
||||
var li = document.createElement("li");
|
||||
li.innerHTML = "<span class='n'>" + t.count + "</span> " +
|
||||
esc(t.label);
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = "view";
|
||||
b.onclick = function () {
|
||||
el("f-tags").value = t.tag_id.split(":")[1];
|
||||
state.filters.tags = [el("f-tags").value];
|
||||
load();
|
||||
};
|
||||
li.appendChild(b);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function loadTags() {
|
||||
apiFetch("/tags").then(function (r) {
|
||||
if (r.status === 200) {
|
||||
state.tags = r.body.tags || [];
|
||||
var sel = el("f-tags");
|
||||
sel.innerHTML = state.tags
|
||||
.map(function (t) {
|
||||
var name = t.tag_id.split(":")[1];
|
||||
return '<option value="' + esc(name) + '">' +
|
||||
esc(t.label) + " (" + t.count +
|
||||
")</option>";
|
||||
}).join("");
|
||||
// rehydrate current selection
|
||||
Array.prototype.forEach.call(sel.options,
|
||||
function (o) {
|
||||
o.selected =
|
||||
state.filters.tags.indexOf(o.value) >= 0;
|
||||
});
|
||||
renderTeaser();
|
||||
} else {
|
||||
// "no tagged bills yet" — degrade, no crash
|
||||
el("topics-teaser").innerHTML =
|
||||
"<li class='meta'>Topic analysis is filling in…</li>";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- auth (D1a local account, dev) ---------- */
|
||||
function signIn(email) {
|
||||
apiFetch("/auth/register", { method: "POST",
|
||||
body: { email: email } }).then(function (r) {
|
||||
if (r.status !== 201) { alert("Sign-in failed"); return; }
|
||||
state.token = r.body.token;
|
||||
localStorage.setItem("dash_token", r.body.token);
|
||||
apiFetch("/prefs", {}).then(function (pr) {
|
||||
state.prefs = pr.status === 200 ? pr.body : null;
|
||||
refreshUI();
|
||||
load();
|
||||
});
|
||||
});
|
||||
}
|
||||
function signOut() {
|
||||
state.token = null;
|
||||
state.prefs = null;
|
||||
state.personal = false;
|
||||
localStorage.removeItem("dash_token");
|
||||
refreshUI();
|
||||
load();
|
||||
}
|
||||
|
||||
function refreshUI() {
|
||||
var has = state.token;
|
||||
el("user-band").hidden = !has;
|
||||
el("anon-cta").hidden = !!has;
|
||||
el("personal-controls").hidden = !has;
|
||||
el("aside-title").textContent = has
|
||||
? "Your watchlist" : "Top topics this session";
|
||||
if (has) {
|
||||
var p = state.prefs || {};
|
||||
el("user-band-text").textContent =
|
||||
"ZIP " + (p.zip || "—") + " · " +
|
||||
((p.watched_legislators || []).length) +
|
||||
" legislators · " +
|
||||
((p.opted_in_tags || []).length) + " topics";
|
||||
renderWatch();
|
||||
renderTopics();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- watch / topic toggles ---------- */
|
||||
function renderWatch() {
|
||||
var c = el("resolve-result");
|
||||
var p = state.prefs || {};
|
||||
var wl = p.watched_legislators || [];
|
||||
var html = "";
|
||||
wl.forEach(function (id) {
|
||||
html += "<div class='candidate'>" +
|
||||
"<button type='button' class='watch-btn on' " +
|
||||
"data-leg='" + esc(id) + "'>✓ watching " +
|
||||
esc(pretty(id)) + "</button>" +
|
||||
"<button type='button' class='unwatch-btn ghost' " +
|
||||
"data-leg='" + esc(id) + "'>remove</button>" +
|
||||
"</div>";
|
||||
});
|
||||
c.innerHTML = html || "";
|
||||
wire(c, "watch");
|
||||
}
|
||||
|
||||
function renderTopics() {
|
||||
var c = el("topic-list");
|
||||
var p = state.prefs || {};
|
||||
var opted = p.opted_in_tags || [];
|
||||
c.innerHTML = (state.tags || []).map(function (t) {
|
||||
var name = t.tag_id.split(":")[1];
|
||||
var on = opted.indexOf(name) >= 0;
|
||||
return "<button type='button' class='topic-chip" +
|
||||
(on ? " on" : "") + "' data-tag='" + esc(name) +
|
||||
"'>" + (on ? "✓ " : "") + esc(t.label) +
|
||||
" <span class='count'>· " + t.count + " bills</span>" +
|
||||
"</button>";
|
||||
}).join("");
|
||||
wire(c, "topic");
|
||||
}
|
||||
|
||||
function toggleWatch(id, on) {
|
||||
var path = on
|
||||
? "/prefs/watch"
|
||||
: "/prefs/watch/" + encodeURIComponent(id);
|
||||
var method = on ? "POST" : "DELETE";
|
||||
var body = on ? { legislator_id: id } : null;
|
||||
apiFetch(path, { method: method, body: body }).then(function (r) {
|
||||
if (r.status === 400 || r.status === 404) {
|
||||
alert(r.body.error && r.body.error.message);
|
||||
}
|
||||
apiFetch("/prefs", {}).then(function (pr) {
|
||||
state.prefs = pr.status === 200 ? pr.body : null;
|
||||
refreshUI();
|
||||
load();
|
||||
announce("Ranking updated");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTopic(tag, on) {
|
||||
var path = on ? "/prefs/topics"
|
||||
: "/prefs/topics/" + encodeURIComponent(tag);
|
||||
var method = on ? "POST" : "DELETE";
|
||||
var body = on ? { tag: tag } : null;
|
||||
apiFetch(path, { method: method, body: body }).then(function (r) {
|
||||
if (r.status === 400) {
|
||||
alert(r.body.error && r.body.error.message);
|
||||
}
|
||||
apiFetch("/prefs", {}).then(function (pr) {
|
||||
state.prefs = pr.status === 200 ? pr.body : null;
|
||||
refreshUI();
|
||||
load();
|
||||
announce("Ranking updated");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function announce(msg) {
|
||||
var a = el("freshness-updated");
|
||||
var old = a.textContent;
|
||||
a.textContent = old + " · " + msg;
|
||||
}
|
||||
|
||||
/* ---------- ZIP resolve (DESIGN §8, §6.4) ---------- */
|
||||
function resolve(zip) {
|
||||
apiFetch("/districts/resolve", { method: "POST",
|
||||
body: { zip: zip } }).then(function (r) {
|
||||
var c = el("resolve-result");
|
||||
var p = state.prefs || {};
|
||||
if (r.status === 200 && r.body.confidence === "single") {
|
||||
p.zip = zip;
|
||||
var d = r.body.districts[0];
|
||||
var mem = d.assembly_member || d.senate_member;
|
||||
p.districts = [d];
|
||||
if (mem && (p.watched_legislators || []).indexOf(
|
||||
mem.legislator_id) === -1) {
|
||||
toggleWatch(mem.legislator_id, true);
|
||||
}
|
||||
(r.body.suggested_topics || []).forEach(function (t) {
|
||||
if ((p.opted_in_tags || []).indexOf(t) === -1)
|
||||
toggleTopic(t, true);
|
||||
});
|
||||
c.innerHTML = "<div class='candidate'>" +
|
||||
"<span class='who'>Resolved: " + esc(d.district_id) +
|
||||
" — " + esc((mem && mem.name) || "") + "</span>" +
|
||||
"</div>" +
|
||||
"<p class='meta'>Watched " +
|
||||
esc((mem && mem.name) || "n/a") +
|
||||
"; suggested topics opted in. " +
|
||||
"Nothing is auto-picked without a confirm here — " +
|
||||
"you can remove any on the right.</p>";
|
||||
load();
|
||||
} else if (r.status === 200 && r.body.confidence ===
|
||||
"ambiguous") {
|
||||
c.innerHTML = "<p class='warning'>" + esc(r.body.note ||
|
||||
"Multiple districts match this ZIP. Confirm one:") +
|
||||
"</p>" +
|
||||
r.body.districts.map(function (d) {
|
||||
var mem = d.assembly_member || d.senate_member;
|
||||
return "<div class='candidate'>" +
|
||||
"<span class='who'>" + esc(d.district_id) +
|
||||
" · " + esc((mem && mem.name) || "unknown") +
|
||||
" <span class='dc'>" +
|
||||
esc((mem && mem.party) || "") + "</span></span>" +
|
||||
"<button type='button' class='confirm-btn ghost' " +
|
||||
"data-dc='" + esc(d.district_id) +
|
||||
"' data-leg='" +
|
||||
esc((mem && mem.legislator_id) || "") + "'>" +
|
||||
"Watch " + esc(d.district_id) + "</button></div>";
|
||||
}).join("");
|
||||
wire(c, "resolve-ambiguous");
|
||||
} else {
|
||||
// zero / out-of-state / other → §6.4 manual fallback
|
||||
var msg = r.body && r.body.error
|
||||
? r.body.error.message
|
||||
: "Could not match that ZIP.";
|
||||
c.innerHTML =
|
||||
"<p class='warning'>" + esc(msg) + "</p>" +
|
||||
"<input type='text' id='manual-dc' placeholder=" +
|
||||
"'Pick a district by hand, e.g. A-14'>" +
|
||||
"<button type='button' id='btn-manual' " +
|
||||
"class='ghost'>Confirm</button>";
|
||||
el("btn-manual").onclick = function () {
|
||||
var dc = el("manual-dc").value.trim();
|
||||
if (dc) resolveManual(dc);
|
||||
};
|
||||
}
|
||||
});
|
||||
state.prefs = state.prefs || {};
|
||||
}
|
||||
|
||||
function resolveManual(dc) {
|
||||
apiFetch("/districts/resolve", { method: "POST",
|
||||
body: { district: dc } }).then(function (r) {
|
||||
if (r.status === 200) {
|
||||
var p = state.prefs || {};
|
||||
p.districts = r.body.districts;
|
||||
var mem = r.body.districts[0].assembly_member ||
|
||||
r.body.districts[0].senate_member;
|
||||
if (mem) toggleWatch(mem.legislator_id, true);
|
||||
load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pretty(id) {
|
||||
var m = id.match(/^(A|S)-(\d+)$/i);
|
||||
return m ? m[1] + m[2] : id;
|
||||
}
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
function wireRows(container) {
|
||||
Array.prototype.forEach.call(
|
||||
container.querySelectorAll(".why-btn"),
|
||||
function (b) {
|
||||
b.onclick = function () {
|
||||
var r = b.closest(".row").querySelector(".reason");
|
||||
if (r) r.classList.toggle("show");
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function wire(container, kind) {
|
||||
if (kind === "watch") {
|
||||
Array.prototype.forEach.call(
|
||||
container.querySelectorAll(".unwatch-btn"),
|
||||
function (b) {
|
||||
b.onclick = function () {
|
||||
toggleWatch(b.getAttribute("data-leg"), false);
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
if (kind === "topic") {
|
||||
Array.prototype.forEach.call(
|
||||
container.querySelectorAll(".topic-chip"),
|
||||
function (b) {
|
||||
b.onclick = function () {
|
||||
var on = b.classList.contains("on");
|
||||
toggleTopic(b.getAttribute("data-tag"), !on);
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
if (kind === "resolve-ambiguous") {
|
||||
Array.prototype.forEach.call(
|
||||
container.querySelectorAll(".confirm-btn"),
|
||||
function (b) {
|
||||
b.onclick = function () {
|
||||
var lid = b.getAttribute("data-leg");
|
||||
if (lid) toggleWatch(lid, true);
|
||||
var name = b.closest(".candidate")
|
||||
.querySelector(".who").textContent;
|
||||
el("resolve-result").innerHTML =
|
||||
"<div class='candidate'><span class='who'>" +
|
||||
esc(name.trim()) +
|
||||
"</span><span class='dc'>confirmed</span>" +
|
||||
"</div>";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
// controls
|
||||
el("f-chamber").onchange = function () {
|
||||
state.filters.chamber = el("f-chamber").value; load();
|
||||
};
|
||||
el("f-date").onchange = function () {
|
||||
var v = el("f-date").value;
|
||||
state.filters.upcoming = (v !== "all");
|
||||
load();
|
||||
};
|
||||
var st;
|
||||
el("f-search").oninput = function () {
|
||||
clearTimeout(st);
|
||||
st = setTimeout(function () {
|
||||
state.filters.q = el("f-search").value; load();
|
||||
}, 300);
|
||||
};
|
||||
el("f-tags").onchange = function () {
|
||||
state.filters.tags = Array.prototype.slice.call(
|
||||
el("f-tags").selectedOptions).map(function (o) {
|
||||
return o.value;
|
||||
});
|
||||
load();
|
||||
};
|
||||
el("f-reason").onchange = function () {
|
||||
state.showReason = el("f-reason").checked;
|
||||
Array.prototype.forEach.call(
|
||||
document.querySelectorAll(".reason"),
|
||||
function (r) { r.classList.toggle("show", state.showReason); });
|
||||
};
|
||||
el("btn-refresh").onclick = load;
|
||||
el("btn-signout").onclick = signOut;
|
||||
el("btn-resolve").onclick = function () {
|
||||
var z = el("zip-input").value.trim();
|
||||
if (z) resolve(z);
|
||||
};
|
||||
el("btn-setup").onclick = function () {
|
||||
var z = el("zip-setup").value.trim();
|
||||
if (z) { signIn(z + "@resident.gov"); }
|
||||
};
|
||||
// unlock CTA
|
||||
var u = el("unlocked");
|
||||
if (u) u.onclick = function () {
|
||||
var em = prompt("Sign in with a resident email (D1a demo):");
|
||||
if (em) signIn(em);
|
||||
};
|
||||
|
||||
// restore session
|
||||
if (state.token) {
|
||||
state.personal = true;
|
||||
apiFetch("/prefs").then(function (r) {
|
||||
if (r.status === 200) {
|
||||
state.prefs = r.body;
|
||||
state.personal = true;
|
||||
} else {
|
||||
state.prefs = null;
|
||||
}
|
||||
refreshUI();
|
||||
load();
|
||||
});
|
||||
}
|
||||
refreshUI();
|
||||
loadTags();
|
||||
load();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>California Legislature Dashboard — Upcoming Legislation</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<meta name="description"
|
||||
content="A monitor of upcoming California Assembly & Senate daily-file
|
||||
hearings. Anonymous overview; a logged-in resident pins their
|
||||
legislators & topics to the top.">
|
||||
</head>
|
||||
<body>
|
||||
<!-- sticky header: wordmark + nav + (logged-in) user band -->
|
||||
<header class="topbar" id="topbar">
|
||||
<a class="wordmark" href="/">CA Legislature<span>Dashboard</span></a>
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<a href="#dashboard" id="nav-dashboard" class="active">Dashboard</a>
|
||||
<a href="#legislators">Legislators</a>
|
||||
<a href="#bills">Bills</a>
|
||||
</nav>
|
||||
<div class="user-band" id="user-band" hidden>
|
||||
<span id="user-band-text">ZIP — · 0 legislators · 0 topics</span>
|
||||
<a href="#prefs" id="nav-prefs">Preferences</a>
|
||||
<button type="button" id="btn-signout">Sign out</button>
|
||||
</div>
|
||||
<div class="anon-cta" id="anon-cta">
|
||||
<a href="#unlocked">Sign in</a>
|
||||
<span class="cta-value">Save your interests → sign in</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<!-- §9 status / context band -->
|
||||
<section class="freshness" id="freshness" aria-live="polite">
|
||||
<span id="freshness-updated">Loading…</span>
|
||||
<span id="freshness-session" class="badge"></span>
|
||||
<span id="freshness-chamber" class="note"></span>
|
||||
<button type="button" id="btn-refresh" class="ghost">Refresh</button>
|
||||
</section>
|
||||
|
||||
<!-- Controls bar -->
|
||||
<section class="controls" id="controls">
|
||||
<input type="search" id="f-search" class="search"
|
||||
placeholder="Search bills… (title, author, committee)"
|
||||
aria-label="Search bills">
|
||||
<label>Chamber
|
||||
<select id="f-chamber">
|
||||
<option value="">All</option>
|
||||
<option value="ASSEMBLY">Assembly</option>
|
||||
<option value="SENATE">Senate</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Date
|
||||
<select id="f-date">
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="week">This week</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<select id="f-tags" multiple size="1" aria-label="Filter by topic"
|
||||
class="tags-filter"></select>
|
||||
<label>Why is this here?
|
||||
<input type="checkbox" id="f-reason" title="Show why each
|
||||
floated bill is in your view">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Logged-in personal controls (ZIP + watch/opt-in) -->
|
||||
<section class="personal-controls" id="personal-controls" hidden>
|
||||
<div class="zip-lookup">
|
||||
<label for="zip-input">Where do you live?</label>
|
||||
<input type="text" id="zip-input" inputmode="numeric"
|
||||
pattern="[0-9]{5}" placeholder="ZIP (e.g. 90210)">
|
||||
<button type="button" id="btn-resolve">Find
|
||||
my legislators</button>
|
||||
</div>
|
||||
<div id="resolve-result" class="resolve-result"></div>
|
||||
<div class="watch-list" id="watch-list"></div>
|
||||
<div class="topic-list" id="topic-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Personal band: floored "Relevant to you" (top N) + setup empty state -->
|
||||
<section class="band" id="personal-band" aria-label="Relevant to you">
|
||||
<h2 class="band-title">Relevant to you
|
||||
<span id="band-count" class="count"></span>
|
||||
<button type="button" id="btn-show-all" class="ghost"
|
||||
hidden>Show all N</button></h2>
|
||||
<div id="band-setup" class="empty-state" hidden>
|
||||
<h3>Pin your view</h3>
|
||||
<p>Enter your ZIP and we'll surface your legislators and the
|
||||
bills that matter to you.</p>
|
||||
<input type="text" id="zip-setup" inputmode="numeric"
|
||||
placeholder="ZIP code">
|
||||
<button type="button" id="btn-setup">Find my legislators</button>
|
||||
</div>
|
||||
<div id="band-list" class="list"></div>
|
||||
</section>
|
||||
|
||||
<!-- All upcoming legislation: the full queue, floated bills marked -->
|
||||
<section class="band" id="all-band">
|
||||
<h2 class="band-title">All upcoming legislation
|
||||
<span id="all-count" class="count"></span></h2>
|
||||
<div id="all-list" class="list"></div>
|
||||
<div id="all-empty" class="empty-state" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- aside: watchlist summary / top topics this session -->
|
||||
<aside class="aside" id="aside">
|
||||
<h3 id="aside-title">Top topics this session</h3>
|
||||
<ul id="topics-teaser" class="teaser"></ul>
|
||||
<a id="unlocked" class="unlock">Unlock your view — pin your
|
||||
legislators & topics to the top →</a>
|
||||
</aside>
|
||||
|
||||
<footer class="footer">
|
||||
<span>Provenance: CA Assembly & Senate daily file ·
|
||||
<a href="#about">About</a> · <a href="#help">Help</a> ·
|
||||
<a href="#privacy">Privacy</a> ·
|
||||
<a href="#accessibility">Accessibility</a></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
/* ============================================================================
|
||||
Legislative Dashboard — visual system (DESIGN_DASHBOARD §9).
|
||||
Government-appropriate, high-contrast, accessible. One accent. No
|
||||
gradients, glass, emoji, or hero. Rhythm via scale + spacing + rules.
|
||||
============================================================================ */
|
||||
|
||||
:root {
|
||||
--ink: #1a1a1a;
|
||||
--muted: #5b5b5b;
|
||||
--paper: #ffffff;
|
||||
--surface: #f4f5f7;
|
||||
--border: #d5d9df;
|
||||
--accent: #0b5394; /* sober blue, AA on paper */
|
||||
--accent-ink: #0a3f6f;
|
||||
--success: #1e7e34; /* watched / on */
|
||||
--warn: #8a5a00;
|
||||
--danger: #b3261e;
|
||||
--asm: #2f5aa7; /* chamber chip tint only */
|
||||
--sen: #7a3b8f;
|
||||
--reason-tag: #0b5394; /* 3px bar: tag match */
|
||||
--reason-leg: #1e7e34; /* 3px bar: legislator match */
|
||||
--focus-ring: #0b5394;
|
||||
--radius: 3px;
|
||||
--gap: 14px;
|
||||
--mono: ui-monospace, "SF Mono", "Menlo", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
line-height: 1.5;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ---- focus: visible 2px ring, never the only signal ---- */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- topbar ---- */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 10px 18px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wordmark { text-decoration: none; color: var(--ink);
|
||||
font-weight: 700; letter-spacing: .02em; font-size: 15px; }
|
||||
.wordmark span { color: var(--accent); }
|
||||
.nav { display: flex; gap: 14px; }
|
||||
.nav a { color: var(--muted); text-decoration: none; font-size: 14px;
|
||||
padding: 6px 2px; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover { color: var(--ink); }
|
||||
.nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.user-band { display: flex; align-items: center; gap: 12px;
|
||||
margin-left: auto; font-size: 13px; color: var(--muted); }
|
||||
.user-band button { font-size: 13px; }
|
||||
.anon-cta { margin-left: auto; text-align: right; }
|
||||
.anon-cta a { color: var(--accent); font-weight: 600; }
|
||||
.cta-value { display: block; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* ---- freshness / status band ---- */
|
||||
.freshness {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 18px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border); font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge { background: var(--paper); border: 1px solid var(--border);
|
||||
padding: 1px 8px; border-radius: 10px; }
|
||||
.note { color: var(--warn); }
|
||||
|
||||
/* ---- controls ---- */
|
||||
.controls {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
|
||||
padding: 12px 18px; border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.controls input, .controls select {
|
||||
font: inherit; font-size: 13px; padding: 6px 8px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink);
|
||||
}
|
||||
.controls .search { min-width: 240px; flex: 1 1 auto; }
|
||||
.controls label { display: inline-flex; align-items: center; gap: 5px;
|
||||
color: var(--muted); }
|
||||
|
||||
/* ---- layout: main + aside ---- */
|
||||
main { max-width: 1100px; margin: 0 auto; }
|
||||
main .controls, main .freshness { max-width: none; }
|
||||
.wrap-grid { display: grid; grid-template-columns: 1fr 360px;
|
||||
gap: 20px; padding: 16px 18px; }
|
||||
@media (max-width: 860px) {
|
||||
.wrap-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---- personal controls ---- */
|
||||
.personal-controls {
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.zip-lookup { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||
.zip-lookup label { display: block; font-size: 12px; color: var(--muted);
|
||||
margin-bottom: 3px; }
|
||||
.zip-lookup input { font: inherit; padding: 8px 10px; width: 160px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.resolve-result { margin-top: 10px; }
|
||||
.candidate { display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); margin: 4px 0; background: var(--paper); }
|
||||
.candidate .who { font-weight: 600; }
|
||||
.candidate .dc { color: var(--muted); font-size: 12px; }
|
||||
.warning { color: var(--danger); font-size: 13px; margin: 6px 0; }
|
||||
|
||||
.watch-list, .topic-list {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.watch-item, .topic-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border: 1px solid var(--border);
|
||||
border-radius: 14px; font-size: 13px; background: var(--paper);
|
||||
}
|
||||
.watch-item.on, .topic-chip.on {
|
||||
border-color: var(--success); color: var(--success);
|
||||
background: #eef7f0; }
|
||||
.topic-chip .count { color: var(--muted); font-size: 11px; }
|
||||
button.chip-toggle { min-width: 120px; }
|
||||
|
||||
/* ---- bands ---- */
|
||||
.band { padding: 16px 18px; border-bottom: 1px solid var(--border); }
|
||||
.band-title { font-size: 14px; text-transform: uppercase; letter-spacing:
|
||||
0.06em; color: var(--muted); margin: 0 0 10px; }
|
||||
.band-title .count { color: var(--ink); font-weight: 700; }
|
||||
#personal-band .band-title { color: var(--accent); }
|
||||
|
||||
.day-heading {
|
||||
font-size: 13px; color: var(--muted); margin: 14px 0 6px;
|
||||
padding-bottom: 4px; border-bottom: 1px solid var(--border); }
|
||||
.day-heading b { color: var(--ink); }
|
||||
|
||||
/* ---- bill row ---- */
|
||||
.row {
|
||||
display: grid; grid-template-columns: 22px 1fr auto;
|
||||
gap: 10px; align-items: start;
|
||||
padding: 10px 10px 10px 0; border-bottom: 1px solid var(--border);
|
||||
border-left: 3px solid transparent; padding-left: 8px;
|
||||
}
|
||||
.row.relevant { background: #f7fafd; }
|
||||
.row.relevant.tag-match { border-left-color: var(--reason-tag); }
|
||||
.row.relevant.leg-match { border-left-color: var(--reason-leg); }
|
||||
.row.relevant.both { border-left-color: var(--accent); }
|
||||
.row .title { font-weight: 600; }
|
||||
.row .meta { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||
.row .num { font-family: var(--mono); font-size: 13px; }
|
||||
.row .why { color: var(--accent); font-size: 12px; cursor: pointer; }
|
||||
.row .reason { font-size: 12px; color: var(--muted); margin-top: 4px;
|
||||
display: none; }
|
||||
.row .reason.show { display: block; }
|
||||
.chip { display: inline-block; font-size: 11px; padding: 1px 7px;
|
||||
border-radius: 10px; border: 1px solid var(--border);
|
||||
margin-right: 5px; }
|
||||
.chip.asm { background: #eef3fb; color: var(--asm); border-color:#cdd9ee; }
|
||||
.chip.sen { background: #f5eef9; color: var(--sen); border-color:#e2d2ea; }
|
||||
.chip.marker { background: var(--accent); color: #fff; border-color:
|
||||
var(--accent); }
|
||||
.tag { display: inline-block; font-size: 11px; color: var(--muted);
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 0 6px; margin: 0 4px 0 0; }
|
||||
.row a { color: var(--accent); text-decoration: none; }
|
||||
.row a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- empty states ---- */
|
||||
.empty-state { padding: 22px; text-align: center; color: var(--muted);
|
||||
border: 1px dashed var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); }
|
||||
.empty-state h3 { color: var(--ink); margin-top: 0; }
|
||||
.empty-state input { margin: 8px 0; padding: 8px; font: inherit;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.escape { color: var(--accent); cursor: pointer; }
|
||||
|
||||
/* ---- aside ---- */
|
||||
.aside { padding: 16px 0; font-size: 13px; }
|
||||
.aside h3 { font-size: 13px; text-transform: uppercase; letter-spacing:
|
||||
0.06em; color: var(--muted); }
|
||||
.teaser { list-style: none; padding: 0; margin: 0 0 14px; }
|
||||
.teaser li { padding: 4px 0; }
|
||||
.teaser .n { color: var(--muted); }
|
||||
.teaser button { margin-left: 6px; font-size: 12px; }
|
||||
.unlock { display: block; margin-top: 10px; padding: 10px;
|
||||
border: 1px solid var(--accent); color: var(--accent);
|
||||
border-radius: var(--radius); text-decoration: none; }
|
||||
.unlock:hover { background: #f7fafd; }
|
||||
|
||||
/* ---- footer ---- */
|
||||
.footer { padding: 18px; font-size: 12px; color: var(--muted);
|
||||
border-top: 1px solid var(--border); }
|
||||
.footer a { color: var(--muted); }
|
||||
|
||||
/* ---- buttons ---- */
|
||||
button { font: inherit; font-size: 13px; padding: 6px 12px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink); cursor: pointer; }
|
||||
button:hover { background: var(--surface); }
|
||||
button.ghost { background: transparent; border-color: var(--border);
|
||||
color: var(--muted); }
|
||||
button.primary { background: var(--accent); color: #fff;
|
||||
border-color: var(--accent); }
|
||||
button.on { background: var(--success); color: #fff;
|
||||
border-color: var(--success); }
|
||||
|
||||
/* motion: minimal. a floated row may fade in once. */
|
||||
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
||||
.row.relevant { animation: fadein 140ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row.relevant { animation: none; }
|
||||
}
|
||||
Reference in new issue
Block a user