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:
1 parent
1919d43bf0
commit
03eeb6bb62
16 files changed
+941
-70
No files matched your search
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user