Files
mediator/static/poll.js
T
2026-06-10 16:53:28 +02:00

203 lines
6.2 KiB
JavaScript

// Poll page: same calendar widget, voters circle the offered days that work.
const pollID = location.pathname.split("/").pop();
const admin = new URLSearchParams(location.search).get("admin") || "";
let poll = null;
let cal = null;
const myPicks = new Set(); // option IDs the current visitor selected
const byDate = new Map(); // dateStr -> option
function $(id) { return document.getElementById(id); }
async function loadPoll() {
const res = await fetch(`/api/polls/${pollID}` + (admin ? `?admin=${encodeURIComponent(admin)}` : ""));
if (!res.ok) {
$("not-found").hidden = false;
return;
}
poll = await res.json();
byDate.clear();
for (const o of poll.options) byDate.set(o.date, o);
renderAll();
}
function voteCount(optionID) {
return poll.votes.filter((v) => v.optionIds.includes(optionID)).length;
}
function renderAll() {
$("poll-view").hidden = false;
$("poll-title").textContent = poll.title;
if (poll.description) {
$("poll-desc").textContent = poll.description;
$("poll-desc").hidden = false;
}
document.title = "mediator — " + poll.title;
const canVote = !poll.closed;
$("vote-card").hidden = !canVote;
$("vote-hint").hidden = !canVote;
$("closed-banner").hidden = !poll.closed;
if (poll.isAdmin) {
$("admin-card").hidden = false;
$("close-btn").disabled = poll.closed;
if (poll.closed) $("close-btn").textContent = "Poll is closed";
}
if (!cal) {
cal = new Calendar($("calendar"), {
canClick: (ds) => canVote && byDate.has(ds),
isOffered: (ds) => byDate.has(ds),
isPicked: (ds) => byDate.has(ds) && myPicks.has(byDate.get(ds).id),
onToggle: (ds) => {
const id = byDate.get(ds).id;
if (myPicks.has(id)) myPicks.delete(id);
else myPicks.add(id);
},
afterToggle: updateSummary,
decorate: (ds, cell) => {
const o = byDate.get(ds);
if (!o) return;
if (o.time) {
const chip = document.createElement("span");
chip.className = "timechip";
chip.textContent = o.time;
cell.appendChild(chip);
}
const n = voteCount(o.id);
if (n > 0) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = "\u2713" + n;
cell.appendChild(badge);
}
},
});
if (poll.options.length > 0) cal.goTo(poll.options[0].date);
} else {
cal.render();
}
updateSummary();
renderResults();
}
function updateSummary() {
const n = myPicks.size;
$("vote-summary").textContent =
n === 0 ? "No days circled yet." : n === 1 ? "1 day circled." : `${n} days circled.`;
}
function renderResults() {
const el = $("results");
el.innerHTML = "";
$("answer-count").textContent =
poll.votes.length === 0 ? "" : `(${poll.votes.length} ${poll.votes.length === 1 ? "person" : "people"})`;
if (poll.votes.length === 0) {
el.innerHTML = '<p class="empty">No answers yet. Be the first!</p>';
return;
}
const counts = poll.options.map((o) => voteCount(o.id));
const best = Math.max(...counts);
poll.options.forEach((o, i) => {
const row = document.createElement("div");
row.className = "result-row" + (counts[i] === best && best > 0 ? " best" : "");
const when = document.createElement("span");
when.className = "when";
when.textContent = formatDate(o.date);
if (o.time) {
const t = document.createElement("span");
t.className = "t";
t.textContent = o.time;
when.appendChild(t);
}
if (counts[i] === best && best > 0) {
const tag = document.createElement("span");
tag.className = "best-tag";
tag.textContent = "works best";
when.appendChild(tag);
}
const who = document.createElement("span");
who.className = "who";
const names = poll.votes.filter((v) => v.optionIds.includes(o.id)).map((v) => v.name);
who.textContent = names.join(", ") || "\u2014";
const count = document.createElement("span");
count.className = "count";
count.textContent = counts[i];
row.append(when, who, count);
el.appendChild(row);
});
}
$("vote-btn").addEventListener("click", async () => {
$("vote-error").textContent = "";
$("vote-ok").hidden = true;
const name = $("voter-name").value.trim();
if (!name) { $("vote-error").textContent = "Enter your name first."; return; }
const btn = $("vote-btn");
btn.disabled = true;
try {
const res = await fetch(`/api/polls/${pollID}/votes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, optionIds: [...myPicks] }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not save your answer.");
$("vote-ok").textContent = "Answer saved. You can change it any time by sending again with the same name.";
$("vote-ok").hidden = false;
await loadPoll();
} catch (e) {
$("vote-error").textContent = e.message;
} finally {
btn.disabled = false;
}
});
async function adminAction(method, path, confirmMsg) {
if (!confirm(confirmMsg)) return;
$("admin-error").textContent = "";
const res = await fetch(path, { method, headers: { "X-Admin-Token": admin } });
const data = await res.json();
if (!res.ok) { $("admin-error").textContent = data.error || "Action failed."; return false; }
return true;
}
$("close-btn").addEventListener("click", async () => {
if (await adminAction("POST", `/api/polls/${pollID}/close`,
"Close this poll? Nobody will be able to answer anymore. This can't be undone.")) {
await loadPoll();
}
});
$("delete-btn").addEventListener("click", async () => {
if (await adminAction("DELETE", `/api/polls/${pollID}`,
"Delete this poll and all answers for good?")) {
location.href = "/";
}
});
loadPoll();
// Refresh results every 30s so the group sees answers roll in.
setInterval(async () => {
if (!poll || document.hidden) return;
const res = await fetch(`/api/polls/${pollID}` + (admin ? `?admin=${encodeURIComponent(admin)}` : ""));
if (!res.ok) return;
poll = await res.json();
byDate.clear();
for (const o of poll.options) byDate.set(o.date, o);
cal.render();
updateSummary();
renderResults();
}, 30000);