/* ============================================================================ 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 = '
' + "Floated: " + parts.join("; ") + "
"; } var url = "https://leginfo.legislature.ca.gov/bill/" + (ev.session || "2025-2026") + "/AB" + billNum(ev); return '
' + '' + chip(ev.chamber_origin) + '
' + esc(ev.bill_number) + "
" + '
' + esc(ev.short_title || ev.bill_number) + "" + "
" + esc(meta) + "
" + "View → " + marker + tags + reasons + '
' + '
' + (state.prefs ? '' : "") + "
" + "
"; } function billNum(ev) { var m = (ev.bill_number || "").match(/(\d+)/); return m ? m[1] : ""; } function esc(s) { return String(s == null ? "" : s) .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 += '
' + esc(label) + "
"; 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; } } /* ---------- empty states (DESIGN §6) ---------- */ function showEmpty(kind, container) { var el = el(container); var h = ""; if (kind === "no-next") { h = "

No hearings scheduled

" + "

No upcoming daily-file items in this window.

" + ""; } else if (kind === "filter") { h = "

No upcoming items match your filters

" + "

· " + " · " + "

"; } else if (kind === "setup") { el.innerHTML = ""; el.hidden = false; el.parentElement.querySelector("#band-setup").hidden = false; return; } else if (kind === "no-tags") { h = "

Topic analysis is filling in

" + "

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 = "

No bills match your " + "watches/topics yet. Add more on the right, or " + ".

"; } 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 = "
  • Topic analysis is filling in…
  • "; } }); } /* ---------- 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 += "
    " + "" + "" + "
    "; }); 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 ""; }).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 = "
    " + "Resolved: " + esc(d.district_id) + " — " + esc((mem && mem.name) || "") + "" + "
    " + "

    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.

    "; load(); } else if (r.status === 200 && r.body.confidence === "ambiguous") { c.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(d.district_id) + " · " + esc((mem && mem.name) || "unknown") + " " + esc((mem && mem.party) || "") + "" + "
    "; }).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 = "

    " + 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 = "
    " + esc(name.trim()) + "confirmed" + "
    "; }; } ); } } 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); })();