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.
743 lines
28 KiB
JavaScript
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, "&").replace(/</g, "<")
|
|
.replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
/* ---------- 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);
|
|
})();
|