Files
kfj001 03eeb6bb62 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.
2026-09-06 19:50:02 -07:00

743 lines
28 KiB
JavaScript

/* ============================================================================
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 "";
}
/* The bill's current legislative state — why it is in the queue at all
(API_CONTRACT §5 "upcoming" item 2: a closed/past bill shouldn't read as
a vague "pending"). Always shown; a closed bill is dimmed; possibly_closed
is flagged when the source can't confirm the state. */
function statusReasonHTML(ev) {
var st = ev.status;
var reason = ev.reason;
if (!st && !reason) return "";
var closed = st === "closed";
var label = closed ? "Closed" : "On file";
var cls = "chip status"
+ (closed ? " closed" : " open")
+ (ev.possibly_closed ? " pclosed" : "");
var why = reason ? esc(reason) : "On file";
var tail = ev.possibly_closed ? " (possibly closed)" : "";
return "<div class='row-reason'"
+ (closed ? " data-closed='1'" : "") + ">"
+ "<span class='" + cls + "'>" + esc(label) + "</span> "
+ "<span class='row-reason-why'>" + why
+ (tail ? " " + esc(tail) : "") + "</span>"
+ "</div>";
}
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 || "time TBD";
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>"
: "";
// Legislative status / reason — always on, above the relevance toggle.
var statusBlock = statusReasonHTML(ev);
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 = ev.bill_id
? "https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id="
+ encodeURIComponent(ev.bill_id)
: "";
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>"
+ statusBlock
+ "<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 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; }
}
/* ---------- 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 = "";
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)";
renderNextDay(env.next_legislative_day);
renderNextBand(env.scheduled_events || []);
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);
})();