feat(dashboard): add forward "what's next" signal + fix upcoming floor

The daily-file bill queue is a backward snapshot, so "upcoming" could only
resurface the latest past day (the 08-31 snapshot read as today's queue) and
there was no "what is scheduled next." leginfo bill status/history are
backward-only, so the genuine future-dated signal is the chambers' "upcoming
events" calendars.

- api.py: floor the "upcoming" view at *today* (not the DB max date) when no
  as_of is pinned; surface next_legislative_day + scheduled_events (next 7
  days) on /bills. Add _today_iso / _next_legislative_day / _scheduled_events,
  all degrading to a recess marker / [] (never 5xx).
- relational.py: schema v2 adds the scheduled_events table +
  store_scheduled_events (idempotent per-day refresh), load_scheduled_events,
  next_legislative_day.
- parsers.py: parse_events_page + classify_event_kind for the chamber events
  calendar; skip empty-title rows (decompose the <time> before the fallback).
- ingest.py + cli.py: --store-events lane (cache-first, recess-safe) that
  parses the calendar HTML into scheduled_events.
- sources.py + config.yaml: register assembly_events / senate_events
  (kind: events, empty_is_ok: true).
- run_ingest.sh: run the events lane after the daily-file lane as non-fatal
  (skippable via CALI_SCRAPER_SKIP_EVENTS=1); daily-file exit still gates cron.
- frontend: "Next legislative day" line + "What's next · next 7 days" band
  with honest recess empty states.
- DASHBOARD.md: document the forward signal + known limitations (leginfo
  backward-only gap, recess empty state, 08-31 snapshot is analysis-only).
- tests: 5 new API tests + 18 parse-events tests; 170 pass, 92% coverage.
This commit is contained in:
kfj001 committed 2026-09-06 19:50:02 -07:00
1 parent 1919d43bf0
commit 03eeb6bb62
16 files changed
+941 -70

No files matched your search

+41 -4
View File
@@ -75,16 +75,42 @@ list:
marker and a "Floated: ..." reason line
- Zero-scored bills remain in their date-grouped order below
## Forward signal: "what's next" (scheduled_events, schema v2)
The daily-file bill queue is a *backward* snapshot — leginfo's bill status and
bill history are past-action logs, so they cannot answer "what is scheduled
next." The only genuine future-dated signal is the chambers' "upcoming events"
calendars (`assembly.ca.gov/events/upcoming`, `senate.ca.gov/calendar`). These
are fetched (`python3 -m scraper ingest --store-events`), parsed into the
`scheduled_events` table (relational schema v2), and surfaced on `/bills` as two
new envelope keys:
- `next_legislative_day` — `{date, kind, chamber, title, source}` (the earliest
event on/after `as_of`), or `{date: null, recess: true}` when nothing is
scheduled.
- `scheduled_events` — the next 7 days of chamber events.
The "Upcoming" filter is a *forward* view: with `upcoming=1` and no explicit
`as_of`, the bill queue floors at **today**, so a stale/older daily-file
snapshot can no longer present itself as "upcoming." Omitting `upcoming`
(the "All" / history view) still returns every row.
The dashboard renders a "Next legislative day" line in the freshness band and a
"What's next · next 7 days" band. A recess (empty calendar) is an *honest*
empty state, not an error.
## Tests
```bash
python3 -m pytest tests/test_dashboard_ranking.py tests/test_dashboard_api.py
python3 -m pytest tests/test_dashboard_ranking.py tests/test_dashboard_api.py \
tests/test_parse_events.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
55 tests (12 ranking + 25 API + 18 parse-events), 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.
confirms static + API endpoints serve. The event parser is tested against
inline fixtures plus the saved `samples/assembly_events_*.html` pages.
## DASHBOARD.md
@@ -110,3 +136,14 @@ cannot be auto-approved).
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.
- **Forward signal gap:** leginfo's bill status/history are *backward-only*
(past actions + status narrative, no future dates), so there is no per-bill
"next step" label. The dashboard's forward answer comes solely from the
chamber events calendars (`scheduled_events`).
- **Recess empty state:** while the Assembly (and at times the Senate) is in
final recess, `/events/upcoming` legitimately returns **0 events**. The
"What's next" band and `next_legislative_day` then show an honest "recess /
unknown" state rather than fabricating a date. This is expected, not a
parsing failure.
- The 2026-08-31 daily-file snapshot is analysis-only; it is *not* re-surfaced
as "upcoming" because the upcoming view floors at today.
+86 -17
View File
@@ -13,6 +13,7 @@ from . import data as _data
from . import ranking
from . import resolve as _resolve
from . import bill_status
from .. import relational
from .data import (load_tag_cloud, list_events,
load_legislators, committee_roster_for)
from .ranking import hearing_sort, full_queue, personal_band
@@ -117,17 +118,25 @@ def _truthy(v):
def handle_bills(req):
q = req.query
# "Upcoming" is a FORWARD view: when the caller asks for upcoming without
# pinning an as_of, floor it at *today*, not the most-recent data date.
# Flooring at today is the fix for "upcoming shows the latest PAST day";
# a None as_of would fall back to the DB max date and re-introduce it.
upcoming = _truthy(q.get("upcoming"))
as_of = q.get("as_of")
if upcoming and not as_of:
as_of = _today_iso()
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"),
)
db_path=req.svc.db_path,
upcoming=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=as_of,
)
if _truthy(q.get("personal")):
uid, err = req.require_auth()
if err:
@@ -143,7 +152,7 @@ def handle_bills(req):
events = full_queue(events)
else:
events = personal_band(
events, int(q.get("band_n") or ranking.PERSONAL_BAND_N))
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 = {}
@@ -151,16 +160,76 @@ def handle_bills(req):
key = e.get("date") or "TBD"
groups[key] = groups.get(key, 0) + 1
chamber_avail = _chamber_availability(items, req.svc.db_path)
# The bill queue is a backward daily-file snapshot; the chamber "upcoming
# events" calendar (scheduled_events, schema v2) is the forward-looking
# signal. Surface "next legislative day" + the next-7-days events so a bill
# like AB-1099 shows what is scheduled next, not just the past. A None
# as_of (the "All"/history view) still answers "next" from today onward.
floor = as_of if as_of else _today_iso()
next_day = _next_legislative_day(req.svc.db_path, floor, q.get("chamber"))
scheduled = _scheduled_events(req.svc.db_path, floor, q.get("chamber"))
body = {
"session": session,
"as_of": q.get("as_of") or _now_iso(),
"chamber_availability": chamber_avail,
"items": items,
"groups": groups,
}
"session": session,
"as_of": as_of or floor,
"chamber_availability": chamber_avail,
"next_legislative_day": next_day,
"scheduled_events": scheduled,
"items": items,
"groups": groups,
}
return 200, body
def _today_iso() -> str:
import datetime
return datetime.date.today().isoformat()
def _next_legislative_day(db_path, as_of, chamber=None):
"""Earliest scheduled chamber event on/after as_of (DESIGN section 6.1).
Degrades gracefully: when the scheduled_events table is absent (pre-v2 DB)
or empty for the window (a recess), returns ``{"date": None, "recess":
True}`` so the UI can show "next legislative day unknown". Never raises.
"""
try:
return relational.next_legislative_day(db_path, as_of=as_of,
chamber=chamber)
except Exception: # noqa: BLE001 - forward source must not break /bills
return {"date": None, "recess": True}
def _scheduled_events(db_path, as_of, chamber=None, window_days=7):
"""Near-term scheduled chamber events (the "next 7 days" band).
Returns events from as_of through as_of+window_days, de-duplicated by
(date, kind, title). Degrades to [] when the table is absent / empty (a
recess). Never raises.
"""
try:
import datetime
start = _today_iso() if not as_of else str(as_of)
d0 = datetime.date.fromisoformat(start[:10])
end = (d0 + datetime.timedelta(days=window_days)).isoformat()
evs = relational.load_scheduled_events(db_path, as_of=start,
chamber=chamber)
seen = set()
out = []
for e in evs:
if e.get("date") and e["date"] > end:
continue
k = (e.get("date"), e.get("kind"), e.get("title"))
if k in seen:
continue
seen.add(k)
out.append(e)
out.sort(key=lambda e: (str(e.get("date") or "z"),
str(e.get("kind") or ""),
str(e.get("title") or "")))
return out
except Exception: # noqa: BLE001
return []
def _client_bill(ev):
out = {k: ev.get(k) for k in _data._EVENT_FIELDS}
out["hearing_sort"] = hearing_sort(ev.get("hearing_time"))
+70 -9
View File
@@ -175,16 +175,74 @@
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; }
}
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) ---------- */
/* ---------- forward "what's next" (scheduled_events, schema v2) -------
The daily-file queue is a backward snapshot; the chamber "upcoming
events" calendar is the only genuine future-dated signal. A recess
(recess: true / empty) is an honest empty state, not an error. */
function eventRowHTML(ev) {
var url = ev.source_url || "";
var kindLabel = ev.kind && ev.kind !== "OTHER"
? esc(ev.kind)
: (ev.chamber ? ev.chamber : "");
return '<div class="ev-row">'
+ '<span class="ev-date">' + esc(niceDate(ev.date || ""))
+ "</span>"
+ '<span class="ev-main">'
+ esc(ev.title || "(untitled)")
+ (url
? " <a href='" + esc(url) + "' target='_blank'"
+ " rel='noopener'>details →</a>"
: "")
+ "</span>"
+ (kindLabel ? '<span class="chip ev-kind">'
+ kindLabel + "</span>" : "")
+ "</div>";
}
function renderNextDay(nd) {
var e = el("freshness-next-day");
if (!e) return;
if (!nd || (nd.recess && !nd.date)) {
e.textContent = "Next legislative day: unknown (recess)";
e.style.color = "var(--warn)";
return;
}
e.textContent = "Next legislative day: "
+ (nd.date ? niceDate(nd.date) : "unknown");
e.style.color = "var(--muted)";
}
function renderNextBand(events) {
var list = el("next-list");
var empty = el("next-empty");
var count = el("next-count");
if (!list || !empty || !count) return;
if (!events.length) {
list.innerHTML = "";
count.textContent = "";
empty.innerHTML =
"<h3>Nothing scheduled in the next 7 days</h3>"
+ "<p>The chambers are in recess. This band fills in as the "
+ "next hearings are posted.</p>";
empty.hidden = false;
return;
}
empty.hidden = true;
count.textContent = events.length
+ (events.length === 1 ? " event" : " events");
list.innerHTML = events.map(eventRowHTML).join("");
}
/* ---------- empty states (DESIGN §6) ---------- */
function showEmpty(kind, container) {
var el = el(container);
var h = "";
@@ -250,6 +308,9 @@
el("freshness-chamber").style.color =
c.SENATE === "available" ? "var(--muted)" : "var(--warn)";
renderNextDay(env.next_legislative_day);
renderNextBand(env.scheduled_events || []);
var items = env.items || [];
state.allItems = items;
var band = items.filter(function (i) {
+15 -6
View File
@@ -33,10 +33,11 @@
<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>
<span id="freshness-updated">Loading…</span>
<span id="freshness-session" class="badge"></span>
<span id="freshness-chamber" class="note"></span>
<span id="freshness-next-day" class="note"></span>
<button type="button" id="btn-refresh" class="ghost">Refresh</button>
</section>
<!-- Controls bar -->
@@ -64,9 +65,17 @@
<input type="checkbox" id="f-reason" title="Show why each
floated bill is in your view">
</label>
</section>
</section>
<!-- Logged-in personal controls (ZIP + watch/opt-in) -->
<!-- What's next: forward-looking chamber events calendar (scheduled_events) -->
<section class="band next-band" id="next-band" aria-label="Next 7 days">
<h2 class="band-title">What's next · next 7 days
<span id="next-count" class="count"></span></h2>
<div id="next-list" class="list next-list"></div>
<div id="next-empty" class="empty-state" hidden></div>
</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>
+13
View File
@@ -139,6 +139,19 @@ button.chip-toggle { min-width: 120px; }
.band-title .count { color: var(--ink); font-weight: 700; }
#personal-band .band-title { color: var(--accent); }
/* ---- forward "what's next" band (scheduled_events) ---- */
.next-band { background: #fbfcff; }
.next-band .band-title { color: var(--accent); }
.ev-row {
display: grid; grid-template-columns: 120px 1fr auto;
gap: 10px; align-items: baseline;
padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border);
}
.ev-date { color: var(--muted); font-size: 13px; white-space: nowrap; }
.ev-main { font-weight: 600; }
.chip.ev-kind { background: #eef3fb; color: var(--asm); border-color: #cdd9ee; }
#freshness-next-day { white-space: nowrap; }
.day-heading {
font-size: 13px; color: var(--muted); margin: 14px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid var(--border); }