/* ============================================================================ 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 '' + label + ""; } 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 ""; } 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 '" + esc(tg) + ""; }).join(""); var marker = rel ? '' + "Relevant to you" : ""; var reasons = ""; if (rel) { var parts = (ev.relevance.matched || []).map(function (x) { return esc(x.value); }); reasons = '
No upcoming daily-file items in this window.
" + ""; } else if (kind === "filter") { h = "· " + " · " + "
"; } else if (kind === "setup") { el.innerHTML = ""; el.hidden = false; el.parentElement.querySelector("#band-setup").hidden = false; return; } else if (kind === "no-tags") { h = "Tag chips appear as the topic service warms. " + "You can still watch legislators above.
"; } 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)"; 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 = ""; } 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 = "" + t.count + " " + 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 '"; }).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 = "" + esc(r.body.note || "Multiple districts match this ZIP. Confirm one:") + "
" + r.body.districts.map(function (d) { var mem = d.assembly_member || d.senate_member; return "" + esc(msg) + "
" + "" + ""; 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 = "