235 lines
7.1 KiB
JavaScript
235 lines
7.1 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),
|
|
months: () => {
|
|
const counts = new Map();
|
|
for (const ds of byDate.keys()) {
|
|
const key = ds.slice(0, 7);
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
}
|
|
return counts;
|
|
},
|
|
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;
|
|
const total = poll.options.length;
|
|
$("vote-summary").textContent =
|
|
n === 0
|
|
? `No days circled yet — ${total} ${total === 1 ? "day" : "days"} offered.`
|
|
: `${n} of ${total} offered 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);
|
|
});
|
|
|
|
const noneWork = poll.votes.filter((v) => v.optionIds.length === 0).map((v) => v.name);
|
|
if (noneWork.length > 0) {
|
|
const row = document.createElement("div");
|
|
row.className = "result-row none-row";
|
|
|
|
const when = document.createElement("span");
|
|
when.className = "when";
|
|
when.textContent = "None at all";
|
|
|
|
const who = document.createElement("span");
|
|
who.className = "who";
|
|
who.textContent = noneWork.join(", ");
|
|
|
|
const count = document.createElement("span");
|
|
count.className = "count";
|
|
count.textContent = noneWork.length;
|
|
|
|
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);
|