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

+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) {