diff --git a/static/calendar.js b/static/calendar.js new file mode 100644 index 0000000..b8d3eec --- /dev/null +++ b/static/calendar.js @@ -0,0 +1,138 @@ +// Shared month-grid calendar. Both pages render the exact same widget. +// +// new Calendar(container, { +// canClick(dateStr) -> bool which days are interactive +// isPicked(dateStr) -> bool draw the highlighter ring +// isOffered(dateStr) -> bool soft marker background (poll page) +// decorate(dateStr, cell) add badges/chips +// onToggle(dateStr) click handler +// }) + +const RING_SVG = + ''; + +const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function pad(n) { return String(n).padStart(2, "0"); } + +function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; } + +function todayStr() { + const t = new Date(); + return dateStr(t.getFullYear(), t.getMonth(), t.getDate()); +} + +function formatDate(ds) { + const [y, m, d] = ds.split("-").map(Number); + return new Date(y, m - 1, d).toLocaleDateString(undefined, { + weekday: "short", day: "numeric", month: "short", year: "numeric", + }); +} + +class Calendar { + constructor(el, opts) { + this.el = el; + this.opts = opts; + const now = new Date(); + this.year = now.getFullYear(); + this.month = now.getMonth(); + this.render(); + } + + goTo(ds) { + const [y, m] = ds.split("-").map(Number); + this.year = y; + this.month = m - 1; + this.render(); + } + + shift(delta) { + const d = new Date(this.year, this.month + delta, 1); + this.year = d.getFullYear(); + this.month = d.getMonth(); + this.render(); + } + + render() { + const o = this.opts; + const first = new Date(this.year, this.month, 1); + const daysInMonth = new Date(this.year, this.month + 1, 0).getDate(); + const lead = (first.getDay() + 6) % 7; // Monday-first + const today = todayStr(); + + this.el.innerHTML = ""; + this.el.classList.add("cal"); + + const head = document.createElement("div"); + head.className = "cal-head"; + const title = document.createElement("div"); + title.className = "cal-title"; + title.textContent = first.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + const nav = document.createElement("div"); + nav.className = "cal-nav"; + for (const [sym, delta, label] of [["\u2190", -1, "Previous month"], ["\u2192", 1, "Next month"]]) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = sym; + b.setAttribute("aria-label", label); + b.addEventListener("click", () => this.shift(delta)); + nav.appendChild(b); + } + head.append(title, nav); + this.el.appendChild(head); + + const grid = document.createElement("div"); + grid.className = "cal-grid"; + for (const d of DOW) { + const h = document.createElement("div"); + h.className = "dow"; + h.textContent = d; + grid.appendChild(h); + } + for (let i = 0; i < lead; i++) { + const blank = document.createElement("div"); + blank.className = "day blank"; + grid.appendChild(blank); + } + for (let d = 1; d <= daysInMonth; d++) { + const ds = dateStr(this.year, this.month, d); + const clickable = o.canClick && o.canClick(ds); + const cell = document.createElement(clickable ? "button" : "div"); + if (clickable) cell.type = "button"; + cell.className = "day"; + cell.dataset.date = ds; + + const num = document.createElement("span"); + num.className = "num"; + num.textContent = d; + cell.appendChild(num); + + if (ds === today) cell.classList.add("today"); + if (ds < today) cell.classList.add("past"); + if (o.isOffered && o.isOffered(ds)) cell.classList.add("offered"); + + if (clickable) { + cell.classList.add("clickable"); + cell.insertAdjacentHTML("beforeend", RING_SVG); + const picked = o.isPicked && o.isPicked(ds); + if (picked) cell.classList.add("picked"); + cell.setAttribute("aria-pressed", picked ? "true" : "false"); + cell.setAttribute("aria-label", formatDate(ds)); + cell.addEventListener("click", () => { + o.onToggle(ds); + // Repaint only this cell for snappy feel. + const nowPicked = o.isPicked(ds); + cell.classList.toggle("picked", nowPicked); + cell.setAttribute("aria-pressed", nowPicked ? "true" : "false"); + if (o.afterToggle) o.afterToggle(); + }); + } + + if (o.decorate) o.decorate(ds, cell); + grid.appendChild(cell); + } + this.el.appendChild(grid); + } +} diff --git a/static/create.js b/static/create.js new file mode 100644 index 0000000..d90c2f5 --- /dev/null +++ b/static/create.js @@ -0,0 +1,112 @@ +// Create page: pick dates on the calendar, optionally give each a time. + +const picked = new Map(); // dateStr -> time ("" if none) +const today = todayStr(); + +const cal = new Calendar(document.getElementById("calendar"), { + canClick: (ds) => ds >= today, + isPicked: (ds) => picked.has(ds), + onToggle: (ds) => { + if (picked.has(ds)) picked.delete(ds); + else picked.set(ds, ""); + }, + afterToggle: renderPickedList, + decorate: (ds, cell) => { + const t = picked.get(ds); + if (t) { + const chip = document.createElement("span"); + chip.className = "timechip"; + chip.textContent = t; + cell.appendChild(chip); + } + }, +}); + +const listEl = document.getElementById("picked-list"); + +function renderPickedList() { + listEl.innerHTML = ""; + if (picked.size === 0) { + listEl.innerHTML = '
Nothing circled yet — tap dates on the calendar above.
'; + return; + } + for (const ds of [...picked.keys()].sort()) { + const row = document.createElement("div"); + row.className = "picked-row"; + + const d = document.createElement("span"); + d.className = "d"; + d.textContent = formatDate(ds); + + const time = document.createElement("input"); + time.type = "time"; + time.value = picked.get(ds); + time.setAttribute("aria-label", "Time for " + formatDate(ds) + " (optional)"); + time.addEventListener("change", () => { + picked.set(ds, time.value); + cal.render(); // refresh time chips + }); + + const rm = document.createElement("button"); + rm.type = "button"; + rm.className = "rm"; + rm.textContent = "Remove"; + rm.addEventListener("click", () => { + picked.delete(ds); + renderPickedList(); + cal.render(); + }); + + row.append(d, time, rm); + listEl.appendChild(row); + } +} + +const errEl = document.getElementById("error"); +const createBtn = document.getElementById("create-btn"); + +createBtn.addEventListener("click", async () => { + errEl.textContent = ""; + const title = document.getElementById("title").value.trim(); + if (!title) { errEl.textContent = "Give the plan a name first."; return; } + if (picked.size === 0) { errEl.textContent = "Circle at least one date on the calendar."; return; } + + createBtn.disabled = true; + try { + const res = await fetch("/api/polls", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + description: document.getElementById("description").value.trim(), + options: [...picked.entries()].map(([date, time]) => ({ date, time })), + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Something went wrong."); + + const shareURL = location.origin + "/p/" + data.id; + const adminURL = shareURL + "?admin=" + data.adminToken; + document.getElementById("share-url").value = shareURL; + document.getElementById("admin-url").value = adminURL; + document.getElementById("open-poll").href = adminURL; + document.getElementById("create-view").hidden = true; + document.getElementById("done-view").hidden = false; + window.scrollTo(0, 0); + } catch (e) { + errEl.textContent = e.message; + } finally { + createBtn.disabled = false; + } +}); + +document.querySelectorAll("[data-copy]").forEach((btn) => { + btn.addEventListener("click", async () => { + const input = document.getElementById(btn.dataset.copy); + input.select(); + try { await navigator.clipboard.writeText(input.value); } catch { document.execCommand("copy"); } + const old = btn.textContent; + btn.textContent = "Copied"; + setTimeout(() => (btn.textContent = old), 1200); + }); +}); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..d97955e --- /dev/null +++ b/static/index.html @@ -0,0 +1,82 @@ + + + + + +Pick every day you'd offer for the get-together, add a time if you already know one, and send your friends a single link.
+ +Nothing circled yet — tap dates on the calendar above.
+Send the share link to your friends. Keep the admin link to yourself — it's the only way to close or delete the poll.
+ +Anyone with this link can close or delete the poll — save it somewhere safe.
+This poll may have been deleted, or the link is incomplete. Ask whoever sent it for a fresh link — or start a new poll.
+Highlighted days are on offer. Circle every one that works for you, then send your answer. Answering again with the same name updates your earlier answer.
+ +Polls can't be edited after creation — only closed (stops new answers) or deleted for good.
+No answers yet. Be the first!
'; + 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);