Initial commit
This commit is contained in:
commit
5f2db08b43
100 files changed
+16985
No files matched your search
@@ -0,0 +1,657 @@
|
||||
/* ============================================================================
|
||||
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 "";
|
||||
}
|
||||
|
||||
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 || '<span class="meta">time TBD</span>';
|
||||
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>"
|
||||
: "";
|
||||
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 = "https://leginfo.legislature.ca.gov/bill/" +
|
||||
(ev.session || "2025-2026") + "/AB" + billNum(ev);
|
||||
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>"
|
||||
+ "<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 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, ">").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; }
|
||||
}
|
||||
|
||||
/* ---------- 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)";
|
||||
|
||||
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);
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>California Legislature Dashboard — Upcoming Legislation</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<meta name="description"
|
||||
content="A monitor of upcoming California Assembly & Senate daily-file
|
||||
hearings. Anonymous overview; a logged-in resident pins their
|
||||
legislators & topics to the top.">
|
||||
</head>
|
||||
<body>
|
||||
<!-- sticky header: wordmark + nav + (logged-in) user band -->
|
||||
<header class="topbar" id="topbar">
|
||||
<a class="wordmark" href="/">CA Legislature<span>Dashboard</span></a>
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<a href="#dashboard" id="nav-dashboard" class="active">Dashboard</a>
|
||||
<a href="#legislators">Legislators</a>
|
||||
<a href="#bills">Bills</a>
|
||||
</nav>
|
||||
<div class="user-band" id="user-band" hidden>
|
||||
<span id="user-band-text">ZIP — · 0 legislators · 0 topics</span>
|
||||
<a href="#prefs" id="nav-prefs">Preferences</a>
|
||||
<button type="button" id="btn-signout">Sign out</button>
|
||||
</div>
|
||||
<div class="anon-cta" id="anon-cta">
|
||||
<a href="#unlocked">Sign in</a>
|
||||
<span class="cta-value">Save your interests → sign in</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<!-- §9 status / context band -->
|
||||
<section class="freshness" id="freshness" aria-live="polite">
|
||||
<span id="freshness-updated">Loading…</span>
|
||||
<span id="freshness-session" class="badge"></span>
|
||||
<span id="freshness-chamber" class="note"></span>
|
||||
<button type="button" id="btn-refresh" class="ghost">Refresh</button>
|
||||
</section>
|
||||
|
||||
<!-- Controls bar -->
|
||||
<section class="controls" id="controls">
|
||||
<input type="search" id="f-search" class="search"
|
||||
placeholder="Search bills… (title, author, committee)"
|
||||
aria-label="Search bills">
|
||||
<label>Chamber
|
||||
<select id="f-chamber">
|
||||
<option value="">All</option>
|
||||
<option value="ASSEMBLY">Assembly</option>
|
||||
<option value="SENATE">Senate</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Date
|
||||
<select id="f-date">
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="week">This week</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<select id="f-tags" multiple size="1" aria-label="Filter by topic"
|
||||
class="tags-filter"></select>
|
||||
<label>Why is this here?
|
||||
<input type="checkbox" id="f-reason" title="Show why each
|
||||
floated bill is in your view">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Logged-in personal controls (ZIP + watch/opt-in) -->
|
||||
<section class="personal-controls" id="personal-controls" hidden>
|
||||
<div class="zip-lookup">
|
||||
<label for="zip-input">Where do you live?</label>
|
||||
<input type="text" id="zip-input" inputmode="numeric"
|
||||
pattern="[0-9]{5}" placeholder="ZIP (e.g. 90210)">
|
||||
<button type="button" id="btn-resolve">Find
|
||||
my legislators</button>
|
||||
</div>
|
||||
<div id="resolve-result" class="resolve-result"></div>
|
||||
<div class="watch-list" id="watch-list"></div>
|
||||
<div class="topic-list" id="topic-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Personal band: floored "Relevant to you" (top N) + setup empty state -->
|
||||
<section class="band" id="personal-band" aria-label="Relevant to you">
|
||||
<h2 class="band-title">Relevant to you
|
||||
<span id="band-count" class="count"></span>
|
||||
<button type="button" id="btn-show-all" class="ghost"
|
||||
hidden>Show all N</button></h2>
|
||||
<div id="band-setup" class="empty-state" hidden>
|
||||
<h3>Pin your view</h3>
|
||||
<p>Enter your ZIP and we'll surface your legislators and the
|
||||
bills that matter to you.</p>
|
||||
<input type="text" id="zip-setup" inputmode="numeric"
|
||||
placeholder="ZIP code">
|
||||
<button type="button" id="btn-setup">Find my legislators</button>
|
||||
</div>
|
||||
<div id="band-list" class="list"></div>
|
||||
</section>
|
||||
|
||||
<!-- All upcoming legislation: the full queue, floated bills marked -->
|
||||
<section class="band" id="all-band">
|
||||
<h2 class="band-title">All upcoming legislation
|
||||
<span id="all-count" class="count"></span></h2>
|
||||
<div id="all-list" class="list"></div>
|
||||
<div id="all-empty" class="empty-state" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- aside: watchlist summary / top topics this session -->
|
||||
<aside class="aside" id="aside">
|
||||
<h3 id="aside-title">Top topics this session</h3>
|
||||
<ul id="topics-teaser" class="teaser"></ul>
|
||||
<a id="unlocked" class="unlock">Unlock your view — pin your
|
||||
legislators & topics to the top →</a>
|
||||
</aside>
|
||||
|
||||
<footer class="footer">
|
||||
<span>Provenance: CA Assembly & Senate daily file ·
|
||||
<a href="#about">About</a> · <a href="#help">Help</a> ·
|
||||
<a href="#privacy">Privacy</a> ·
|
||||
<a href="#accessibility">Accessibility</a></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
/* ============================================================================
|
||||
Legislative Dashboard — visual system (DESIGN_DASHBOARD §9).
|
||||
Government-appropriate, high-contrast, accessible. One accent. No
|
||||
gradients, glass, emoji, or hero. Rhythm via scale + spacing + rules.
|
||||
============================================================================ */
|
||||
|
||||
:root {
|
||||
--ink: #1a1a1a;
|
||||
--muted: #5b5b5b;
|
||||
--paper: #ffffff;
|
||||
--surface: #f4f5f7;
|
||||
--border: #d5d9df;
|
||||
--accent: #0b5394; /* sober blue, AA on paper */
|
||||
--accent-ink: #0a3f6f;
|
||||
--success: #1e7e34; /* watched / on */
|
||||
--warn: #8a5a00;
|
||||
--danger: #b3261e;
|
||||
--asm: #2f5aa7; /* chamber chip tint only */
|
||||
--sen: #7a3b8f;
|
||||
--reason-tag: #0b5394; /* 3px bar: tag match */
|
||||
--reason-leg: #1e7e34; /* 3px bar: legislator match */
|
||||
--focus-ring: #0b5394;
|
||||
--radius: 3px;
|
||||
--gap: 14px;
|
||||
--mono: ui-monospace, "SF Mono", "Menlo", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
line-height: 1.5;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ---- focus: visible 2px ring, never the only signal ---- */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- topbar ---- */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 10px 18px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wordmark { text-decoration: none; color: var(--ink);
|
||||
font-weight: 700; letter-spacing: .02em; font-size: 15px; }
|
||||
.wordmark span { color: var(--accent); }
|
||||
.nav { display: flex; gap: 14px; }
|
||||
.nav a { color: var(--muted); text-decoration: none; font-size: 14px;
|
||||
padding: 6px 2px; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover { color: var(--ink); }
|
||||
.nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.user-band { display: flex; align-items: center; gap: 12px;
|
||||
margin-left: auto; font-size: 13px; color: var(--muted); }
|
||||
.user-band button { font-size: 13px; }
|
||||
.anon-cta { margin-left: auto; text-align: right; }
|
||||
.anon-cta a { color: var(--accent); font-weight: 600; }
|
||||
.cta-value { display: block; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* ---- freshness / status band ---- */
|
||||
.freshness {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 18px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border); font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge { background: var(--paper); border: 1px solid var(--border);
|
||||
padding: 1px 8px; border-radius: 10px; }
|
||||
.note { color: var(--warn); }
|
||||
|
||||
/* ---- controls ---- */
|
||||
.controls {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
|
||||
padding: 12px 18px; border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.controls input, .controls select {
|
||||
font: inherit; font-size: 13px; padding: 6px 8px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink);
|
||||
}
|
||||
.controls .search { min-width: 240px; flex: 1 1 auto; }
|
||||
.controls label { display: inline-flex; align-items: center; gap: 5px;
|
||||
color: var(--muted); }
|
||||
|
||||
/* ---- layout: main + aside ---- */
|
||||
main { max-width: 1100px; margin: 0 auto; }
|
||||
main .controls, main .freshness { max-width: none; }
|
||||
.wrap-grid { display: grid; grid-template-columns: 1fr 360px;
|
||||
gap: 20px; padding: 16px 18px; }
|
||||
@media (max-width: 860px) {
|
||||
.wrap-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---- personal controls ---- */
|
||||
.personal-controls {
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.zip-lookup { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||
.zip-lookup label { display: block; font-size: 12px; color: var(--muted);
|
||||
margin-bottom: 3px; }
|
||||
.zip-lookup input { font: inherit; padding: 8px 10px; width: 160px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.resolve-result { margin-top: 10px; }
|
||||
.candidate { display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); margin: 4px 0; background: var(--paper); }
|
||||
.candidate .who { font-weight: 600; }
|
||||
.candidate .dc { color: var(--muted); font-size: 12px; }
|
||||
.warning { color: var(--danger); font-size: 13px; margin: 6px 0; }
|
||||
|
||||
.watch-list, .topic-list {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.watch-item, .topic-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border: 1px solid var(--border);
|
||||
border-radius: 14px; font-size: 13px; background: var(--paper);
|
||||
}
|
||||
.watch-item.on, .topic-chip.on {
|
||||
border-color: var(--success); color: var(--success);
|
||||
background: #eef7f0; }
|
||||
.topic-chip .count { color: var(--muted); font-size: 11px; }
|
||||
button.chip-toggle { min-width: 120px; }
|
||||
|
||||
/* ---- bands ---- */
|
||||
.band { padding: 16px 18px; border-bottom: 1px solid var(--border); }
|
||||
.band-title { font-size: 14px; text-transform: uppercase; letter-spacing:
|
||||
0.06em; color: var(--muted); margin: 0 0 10px; }
|
||||
.band-title .count { color: var(--ink); font-weight: 700; }
|
||||
#personal-band .band-title { color: var(--accent); }
|
||||
|
||||
.day-heading {
|
||||
font-size: 13px; color: var(--muted); margin: 14px 0 6px;
|
||||
padding-bottom: 4px; border-bottom: 1px solid var(--border); }
|
||||
.day-heading b { color: var(--ink); }
|
||||
|
||||
/* ---- bill row ---- */
|
||||
.row {
|
||||
display: grid; grid-template-columns: 22px 1fr auto;
|
||||
gap: 10px; align-items: start;
|
||||
padding: 10px 10px 10px 0; border-bottom: 1px solid var(--border);
|
||||
border-left: 3px solid transparent; padding-left: 8px;
|
||||
}
|
||||
.row.relevant { background: #f7fafd; }
|
||||
.row.relevant.tag-match { border-left-color: var(--reason-tag); }
|
||||
.row.relevant.leg-match { border-left-color: var(--reason-leg); }
|
||||
.row.relevant.both { border-left-color: var(--accent); }
|
||||
.row .title { font-weight: 600; }
|
||||
.row .meta { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||
.row .num { font-family: var(--mono); font-size: 13px; }
|
||||
.row .why { color: var(--accent); font-size: 12px; cursor: pointer; }
|
||||
.row .reason { font-size: 12px; color: var(--muted); margin-top: 4px;
|
||||
display: none; }
|
||||
.row .reason.show { display: block; }
|
||||
.chip { display: inline-block; font-size: 11px; padding: 1px 7px;
|
||||
border-radius: 10px; border: 1px solid var(--border);
|
||||
margin-right: 5px; }
|
||||
.chip.asm { background: #eef3fb; color: var(--asm); border-color:#cdd9ee; }
|
||||
.chip.sen { background: #f5eef9; color: var(--sen); border-color:#e2d2ea; }
|
||||
.chip.marker { background: var(--accent); color: #fff; border-color:
|
||||
var(--accent); }
|
||||
.tag { display: inline-block; font-size: 11px; color: var(--muted);
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 0 6px; margin: 0 4px 0 0; }
|
||||
.row a { color: var(--accent); text-decoration: none; }
|
||||
.row a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- empty states ---- */
|
||||
.empty-state { padding: 22px; text-align: center; color: var(--muted);
|
||||
border: 1px dashed var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); }
|
||||
.empty-state h3 { color: var(--ink); margin-top: 0; }
|
||||
.empty-state input { margin: 8px 0; padding: 8px; font: inherit;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.escape { color: var(--accent); cursor: pointer; }
|
||||
|
||||
/* ---- aside ---- */
|
||||
.aside { padding: 16px 0; font-size: 13px; }
|
||||
.aside h3 { font-size: 13px; text-transform: uppercase; letter-spacing:
|
||||
0.06em; color: var(--muted); }
|
||||
.teaser { list-style: none; padding: 0; margin: 0 0 14px; }
|
||||
.teaser li { padding: 4px 0; }
|
||||
.teaser .n { color: var(--muted); }
|
||||
.teaser button { margin-left: 6px; font-size: 12px; }
|
||||
.unlock { display: block; margin-top: 10px; padding: 10px;
|
||||
border: 1px solid var(--accent); color: var(--accent);
|
||||
border-radius: var(--radius); text-decoration: none; }
|
||||
.unlock:hover { background: #f7fafd; }
|
||||
|
||||
/* ---- footer ---- */
|
||||
.footer { padding: 18px; font-size: 12px; color: var(--muted);
|
||||
border-top: 1px solid var(--border); }
|
||||
.footer a { color: var(--muted); }
|
||||
|
||||
/* ---- buttons ---- */
|
||||
button { font: inherit; font-size: 13px; padding: 6px 12px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink); cursor: pointer; }
|
||||
button:hover { background: var(--surface); }
|
||||
button.ghost { background: transparent; border-color: var(--border);
|
||||
color: var(--muted); }
|
||||
button.primary { background: var(--accent); color: #fff;
|
||||
border-color: var(--accent); }
|
||||
button.on { background: var(--success); color: #fff;
|
||||
border-color: var(--success); }
|
||||
|
||||
/* motion: minimal. a floated row may fade in once. */
|
||||
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
||||
.row.relevant { animation: fadein 140ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row.relevant { animation: none; }
|
||||
}
|
||||
Reference in new issue
Block a user