Initial commit

This commit is contained in:
kfj001 committed 2026-09-06 10:18:26 -07:00
commit 5f2db08b43
100 files changed
+16985

No files matched your search

+112
View File
@@ -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.
+45
View File
@@ -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
+438
View File
@@ -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)
+115
View File
@@ -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
+84
View File
@@ -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())
+335
View File
@@ -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
+181
View File
@@ -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)
+310
View File
@@ -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)
+141
View File
@@ -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)
+186
View File
@@ -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())
+657
View File
@@ -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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/* ---------- 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);
})();
+125
View File
@@ -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 &amp; Senate daily-file
hearings. Anonymous overview; a logged-in resident pins their
legislators &amp; topics to the top.">
</head>
<body>
<!-- sticky header: wordmark + nav + (logged-in) user band -->
<header class="topbar" id="topbar">
<a class="wordmark" href="/">CA&nbsp;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 &amp; topics to the top →</a>
</aside>
<footer class="footer">
<span>Provenance: CA Assembly &amp; 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>
+222
View File
@@ -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; }
}