/* UTC seconds are the only identity of a slot, including across display zones. */ const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const esc = (value) => String(value).replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ], ); const icon = (name) => ``; const icons = () => lucide.createIcons(); const app = $("#app"); const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const dark = matchMedia("(prefers-color-scheme: dark)"); let poll, draft = null, editor = null, selected = null, displayZone = localZone; let zones = [], eventSource, painting = false, paintMode = "yes", pendingLive = false; let gridCells = [], noticeTimer; const dateFormatters = new Map(); const route = location.pathname.split("/").filter(Boolean); const cleanCode = (value) => value .replace(/[-\s]/g, "") .toUpperCase() .replace(/O/g, "0") .replace(/[IL]/g, "1"); let code = cleanCode(route[0] || ""); const adminToken = code.length === 12 ? code : ""; let hoverCloseTimer, touchPopover = false; async function api(path, method = "GET", body, admin = false) { const response = await fetch(`/api${path}`, { method, headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...(admin ? { Authorization: `Bearer ${adminToken}` } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }); const result = await response.json(); if (!response.ok) { const detail = result.detail; const message = typeof detail === "string" ? detail : Array.isArray(detail) ? detail.map((e) => e.msg).join(" ") : detail?.message; throw Object.assign(new Error(message || "The request failed."), { detail, status: response.status, }); } return result; } function notice(message, persistent = false) { clearTimeout(noticeTimer); $("#notice").innerHTML = `${esc(message)}`; $("#notice").hidden = false; $("#notice button").onclick = () => ($("#notice").hidden = true); icons(); if (!persistent) noticeTimer = setTimeout(() => ($("#notice").hidden = true), 6000); } async function busy(button, work) { if (button.disabled) return; const original = button.innerHTML; button.disabled = true; button.classList.add("busy"); button.innerHTML = icon("loader-circle") + "Saving"; icons(); try { await work(); } catch (error) { notice(error.message, true); } finally { button.disabled = false; button.classList.remove("busy"); button.innerHTML = original; icons(); } } function grouped(value, size = 3) { return value.match(new RegExp(`.{1,${size}}`, "g")).join("-"); } function codeHtml(value) { return `${grouped(value) .split("") .map((c) => (/\d/.test(c) ? `${c}` : esc(c))) .join("")}`; } function zoneOptions(current) { return zones .map( (z) => ``, ) .join(""); } function parts(seconds, zone = displayZone) { if (!dateFormatters.has(zone)) dateFormatters.set( zone, new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }), ); const values = Object.fromEntries( dateFormatters .get(zone) .formatToParts(new Date(seconds * 1000)) .map((p) => [p.type, p.value]), ); return { day: `${values.year}-${values.month}-${values.day}`, clock: `${values.hour}:${values.minute}`, minute: +values.hour * 60 + +values.minute, }; } function fullTime(seconds) { return new Intl.DateTimeFormat(undefined, { timeZone: displayZone, month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).format(new Date(seconds * 1000)); } function shortDate(day) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC", }).format(new Date(`${day}T12:00:00Z`)); } function dateHeading(day) { const date = new Date(`${day}T12:00:00Z`); return `${esc(new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(date))}${esc(shortDate(day))}`; } function timeRange(start, end) { const a = parts(start), b = parts(end); const nextDay = new Date(new Date(a.day + "T12:00:00Z").getTime() + 86400000) .toISOString() .slice(0, 10); return `${fullTime(start)} - ${a.day === b.day ? b.clock : b.clock === "00:00" && b.day === nextDay ? "24:00" : fullTime(end)}`; } function home() { app.innerHTML = `

Timepoll

Create a poll

${icon("calendar-plus")}Create poll

Join a poll

`; $("#join").onsubmit = (event) => { event.preventDefault(); const value = new FormData(event.target) .get("code") .trim() .replace(/[\s-]/g, "") .toUpperCase() .replace(/O/g, "0") .replace(/[IL]/g, "1"); if (!/^[0-9A-HJKMNP-TV-Z]{9}$/.test(value)) return notice("Enter a nine-character event code."); location.href = `/${grouped(value)}`; }; icons(); } function settingsPayload() { return { title: $("#event-title").value, timezone: editor.timezone, fixed_timezone: $("#fixed-zone").checked, minutes: editor.minutes, slots: editor.slots, blocked: [...editor.blocked], settings_revision: poll?.settings_revision || 0, }; } function syncDatesFromSlots() { editor.dates = new Set( editor.slots.flatMap((s) => { const first = parts(s, editor.timezone).day, last = parts(s + editor.minutes * 60 - 1, editor.timezone).day; return first === last ? [first] : [first, last]; }), ); if (editor.slots.length) { const first = editor.slots[0], firstDay = parts(first, editor.timezone).day; const daySlots = editor.slots.filter( (s) => parts(s, editor.timezone).day === firstDay, ); const last = parts(daySlots.at(-1) + editor.minutes * 60, editor.timezone); renderTimeControls( parts(first, editor.timezone).clock, last.day !== firstDay ? "24:00" : last.clock, ); } renderDates(); } function renderTimeControls(from = "09:00", until = "17:00") { const minutes = (value) => { const [h, m] = value.split(":").map(Number); return h * 60 + m; }; const step = editor.minutes; const first = Math.floor(minutes(from) / step) * step, last = Math.ceil(minutes(until) / step) * step; for (const [selector, start, end, selected] of [ ["#start-time", 0, 1440 - step, first], ["#end-time", step, 1440, last], ]) { $(selector).innerHTML = Array.from( { length: (end - start) / step + 1 }, (_, i) => { const n = start + i * step, value = `${String(Math.floor(n / 60)).padStart(2, "0")}:${String(n % 60).padStart(2, "0")}`; return ``; }, ).join(""); } } function renderDates() { $("#date-count").textContent = `${editor.dates.size} added`; $("#dates").innerHTML = [...editor.dates] .sort() .map( (d) => ``, ) .join("") || 'No dates added'; $$("#dates button").forEach( (b) => (b.onclick = () => { editor.dates.delete(b.dataset.date); editor.dirty = true; editor.rangeDirty = true; renderDates(); }), ); icons(); } function editPage(existing = false) { displayZone = existing ? poll.timezone : localZone; editor = { timezone: displayZone, minutes: poll?.minutes || 30, slots: poll?.slots || [], blocked: new Set(poll?.blocked || []), dates: new Set(), dirty: false, rangeDirty: false, }; app.innerHTML = `

${existing ? "Edit poll" : "Create a poll"}

${existing ? `Public poll` : ""}

Dates and times

Dates in this poll

Unavailable times (optional)

`; paintMode = "block"; renderTimeControls(); if (existing) syncDatesFromSlots(); else renderDates(); $("#editor-form").addEventListener("input", () => (editor.dirty = true)); for (const selector of ["#start-time", "#end-time"]) $(selector).onchange = () => (editor.rangeDirty = true); for (const selector of ["#date-from", "#date-to"]) $(selector).oninput = () => { const from = $("#date-from").value, to = $("#date-to").value || from; $("#add-range").disabled = !from || to < from; }; $("#single-date").oninput = () => ($("#add-date").disabled = !$("#single-date").value); $("#add-range").onclick = () => { const from = $("#date-from").value, to = $("#date-to").value || from; if (!from || to < from) return notice("Choose a valid date range."); const first = new Date(from + "T12:00:00Z"), last = new Date(to + "T12:00:00Z"); if ((last - first) / 86400000 > 365) return notice("Choose at most 366 dates."); for (let d = first; d <= last; d.setUTCDate(d.getUTCDate() + 1)) editor.dates.add(d.toISOString().slice(0, 10)); editor.dirty = editor.rangeDirty = true; $("#date-from").value = $("#date-to").value = ""; $("#add-range").disabled = true; renderDates(); }; $("#add-date").onclick = () => { const day = $("#single-date").value; if (day) { editor.dates.add(day); editor.dirty = editor.rangeDirty = true; $("#single-date").value = ""; $("#add-date").disabled = true; renderDates(); } }; $("#event-zone").onchange = () => { editor.timezone = displayZone = $("#event-zone").value; editor.dirty = true; if (editor.slots.length) { syncDatesFromSlots(); editor.rangeDirty = false; } renderGrid(); }; $("#slot-size").onchange = () => busy($("#apply-range"), async () => { const old = editor.minutes; const from = $("#start-time").value, until = $("#end-time").value; editor.minutes = +$("#slot-size").value; renderTimeControls(from, until); if (editor.slots.length) { try { if (editor.rangeDirty) await applyRange(); else { const result = await api(`/resize?old_minutes=${old}`, "POST", { ...settingsPayload(), title: $("#event-title").value || "Untitled", }); editor.slots = result.slots; editor.blocked = new Set(result.blocked); syncDatesFromSlots(); } } catch (error) { editor.minutes = old; $("#slot-size").value = old; renderTimeControls(from, until); throw error; } } editor.dirty = true; renderGrid(); }); async function applyRange() { if ($("#date-from").value || $("#date-to").value || $("#single-date").value) throw new Error( "Add the pending dates to Dates in this poll first, or clear the date fields.", ); const result = await api("/schedule", "POST", { dates: [...editor.dates], start: $("#start-time").value, end: $("#end-time").value, minutes: editor.minutes, timezone: editor.timezone, }); editor.slots = result.slots; editor.blocked = new Set( [...editor.blocked].filter((s) => editor.slots.includes(s)), ); editor.dirty = true; editor.rangeDirty = false; renderGrid(); } $("#apply-range").onclick = () => busy($("#apply-range"), applyRange); $("#blackout-mode").onclick = (e) => { const b = e.target.closest("button"); if (!b) return; paintMode = b.dataset.mode; $$("#blackout-mode button").forEach((x) => x.setAttribute("aria-pressed", x === b), ); $(".grid")?.classList.toggle("editing", paintMode !== "pan"); }; $("#cancel-editor").onclick = () => { if (!editor.dirty || confirm("Discard unsaved changes?")) { editor.dirty = false; location.href = existing ? `/${grouped(code)}` : "/"; } }; $("#editor-form").onsubmit = (event) => { event.preventDefault(); busy($("#editor-form button[type=submit]"), async () => { if ( $("#date-from").value || $("#date-to").value || $("#single-date").value ) throw new Error( "Add the pending dates to Dates in this poll first, or clear the date fields.", ); if (editor.rangeDirty || !editor.slots.length) await applyRange(); const body = settingsPayload(); if (existing) { try { await api(`/polls/${code}`, "PUT", body, true); } catch (error) { if (error.detail?.code !== "vote_loss" || !confirm(error.message)) throw error; await api( `/polls/${code}`, "PUT", { ...body, confirm_loss: true }, true, ); } editor.dirty = false; poll = await api(`/polls/${code}`); editPage(true); notice("Poll saved."); } else { const result = await api("/polls", "POST", body); editor.dirty = false; showLinks(result); } }); }; renderGrid(); icons(); } function showLinks(result) { editor = null; const publicLink = `${location.origin}/${grouped(result.code)}`; const privateLink = `${location.origin}/${grouped(result.admin_token, 4)}`; app.innerHTML = ``; $$(".link-row button").forEach( (b) => (b.onclick = () => copyText(b.previousElementSibling.value, b.previousElementSibling)), ); icons(); } async function copyText(text, input) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); notice("Link copied."); return; } catch (error) { if (error.name !== "NotAllowedError") throw error; } } // The legacy command still permits a user-initiated copy on plain HTTP. const previousFocus = document.activeElement; const temporary = document.createElement("textarea"); temporary.value = text; temporary.readOnly = true; temporary.className = "clipboard-buffer"; document.body.append(temporary); temporary.select(); let copied; try { copied = document.execCommand("copy"); } finally { temporary.remove(); previousFocus?.focus({ preventScroll: true }); } if (copied) { notice("Link copied."); return; } if (input) { input.focus(); input.select(); notice("Link selected. Copy it with your browser."); } else prompt("Copy this link:", text); } function publicPage() { document.title = `${poll.title} - Timepoll`; app.innerHTML = `

${esc(poll.title)}

${codeHtml(poll.code)}
AvailableEveryoneIncludes maybeIndividual
`; $("#view-zone").onchange = () => { displayZone = $("#view-zone").value; hidePopover(); renderGrid(); renderSummaries(); }; $("#share").onclick = () => copyText(`${location.origin}/${grouped(poll.code)}`); renderParticipants(); renderActions(); renderGrid(); renderSummaries(); icons(); } function selectPerson(id) { if (draft) return; selected = selected === id ? null : id; hidePopover(); renderParticipants(); renderActions(); renderGrid(); } function renderParticipants() { $("#participants").innerHTML = `
${poll.people.map((p) => ``).join("")}
`; $("#everyone").onclick = () => { selected = null; renderParticipants(); renderActions(); renderGrid(); }; $$("#participants [data-person]").forEach( (b) => (b.onclick = () => selectPerson(+b.dataset.person)), ); $("#participant-count").textContent = `${poll.people.length} participant${poll.people.length === 1 ? "" : "s"}`; } function startDraft(person) { draft = { id: person?.id, name: person?.name || $("#new-name").value.trim().replace(/\s+/g, " "), votes: { ...(person?.votes || {}) }, revision: person?.revision || 0, schedule_revision: poll.schedule_revision, }; if ( !person && poll.people.some( (p) => p.name.normalize("NFKC").toLocaleLowerCase() === draft.name.normalize("NFKC").toLocaleLowerCase(), ) ) { draft = null; return notice( "That name is already in this poll. Choose a different name.", ); } selected = person?.id || null; paintMode = "yes"; hidePopover(); renderParticipants(); renderActions(); renderGrid(); } function renderActions() { const person = poll.people.find((p) => p.id === selected); if (draft) { $("#vote-actions").innerHTML = `
${esc(draft.name)}
${[ ["yes", "check", "Yes"], ["maybe", "minus", "Maybe"], ["no", "eraser", "Unavailable"], ["pan", "hand", ""], ] .map( ([mode, symbol, label]) => ``, ) .join( "", )}
`; $("#vote-mode").onclick = (e) => { const b = e.target.closest("button"); if (!b) return; paintMode = b.dataset.mode; $$("#vote-mode button").forEach((x) => x.setAttribute("aria-pressed", x === b), ); $(".grid")?.classList.toggle("editing", paintMode !== "pan"); }; $("#cancel-votes").onclick = () => { draft = null; selected = null; renderParticipants(); renderActions(); renderGrid(); }; $("#save-votes").onclick = () => busy($("#save-votes"), async () => { await api( `/polls/${code}/people${draft.id ? "/" + draft.id : ""}`, draft.id ? "PUT" : "POST", draft, ); draft = null; selected = null; poll = await api(`/polls/${code}`); renderParticipants(); renderActions(); renderGrid(); renderSummaries(); notice("Votes saved."); }); } else if (person) { $("#vote-actions").innerHTML = `
${esc(person.name)}
`; $("#edit-person").onclick = () => startDraft(person); $("#delete-person").onclick = () => { if (confirm(`Remove ${person.name} and all their votes?`)) busy($("#delete-person"), async () => { await api( `/polls/${code}/people/${person.id}?revision=${person.revision}`, "DELETE", ); selected = null; poll = await api(`/polls/${code}`); renderParticipants(); renderActions(); renderGrid(); renderSummaries(); }); }; } else { const previous = $("#new-name")?.value || ""; $("#vote-actions").innerHTML = `
`; $("#new-voter").onsubmit = (e) => { e.preventDefault(); if ($("#new-name").value.trim()) startDraft(); }; } icons(); } function cellsFor(slots, minutes) { const cells = []; for (const slot of slots) { const first = parts(slot), finish = slot + minutes * 60; let split = finish; if (parts(finish - 1).day !== first.day) { for (let t = slot + 60; t < finish; t += 60) if (parts(t).day !== first.day) { split = t; break; } } cells.push({ slot, start: slot, end: split, ...first }); if (split < finish) cells.push({ slot, start: split, end: finish, ...parts(split) }); } const occurrences = new Map(); for (const c of cells) { const key = `${c.day}/${c.clock}`, fold = occurrences.get(key) || 0; occurrences.set(key, fold + 1); c.row = Math.floor(c.minute / 60) * 120 + fold * 60 + (c.minute % 60); c.fold = fold; } return cells; } function slotStats(slot) { if (draft) { const vote = draft.votes[slot]; return { yes: vote === "yes" ? 1 : 0, maybe: vote === "maybe" ? 1 : 0, n: 1, individual: true, signature: vote || "no", peers: participantStats( slot, poll.people.filter((p) => p.id !== draft.id), ), }; } const people = selected === null ? poll.people : poll.people.filter((p) => p.id === selected); return participantStats(slot, people, selected !== null); } function participantStats(slot, people, individual = false) { const yes = people.filter((p) => p.votes[slot] === "yes"), maybe = people.filter((p) => p.votes[slot] === "maybe"); return { yes: yes.length, maybe: maybe.length, n: people.length, individual, signature: yes.map((p) => p.id).join(",") + "|" + maybe.map((p) => p.id).join(","), }; } function cellStyle(stats, prefix = "") { const count = stats.yes + stats.maybe, ratio = stats.n ? count / stats.n : 0; const peers = stats.peers ? cellStyle(stats.peers, "peer-") : ""; if (!count) return peers; const hue = stats.individual ? 210 : ratio === 1 ? 210 : 150, saturation = stats.individual ? 7 : ratio === 1 ? 65 : 42; const light = dark.matches ? 23 + ratio * 19 : 94 - ratio * 28; return `${peers}--${prefix}cell:hsl(${hue} ${saturation}% ${light}%);--${prefix}spacing:${(4 + 10 * Math.exp((-3 * stats.maybe) / Math.max(1, stats.n))).toFixed(2)}px;`; } function renderGrid() { const source = editor || poll, target = $("#grid"); if (!target) return; hidePopover(); const savedScroll = $(".grid-scroll", target); const scroll = savedScroll ? [savedScroll.scrollLeft, savedScroll.scrollTop] : [0, 0]; if (!source.slots.length) { target.innerHTML = '

No time slots selected

'; return; } gridCells = cellsFor(source.slots, source.minutes); const days = [...new Set(gridCells.map((c) => c.day))].sort(), rows = [...new Set(gridCells.map((c) => c.row))].sort((a, b) => a - b); const lookup = new Map(gridCells.map((c) => [`${c.day}/${c.row}`, c])); const blocked = new Set(source.blocked), index = new Map(gridCells.map((c, i) => [c, i])), slotSet = new Set(source.slots); let body = "", previousRow = null; for (const row of rows) { const example = gridCells.find((c) => c.row === row); if (previousRow !== null) { const prev = gridCells.find((c) => c.row === previousRow); if (example.minute - prev.minute > source.minutes) body += `${days.map(() => "").join("")}`; } const hourMark = source.minutes < 60 && example.minute % 60 === 0; body += `${example.clock}${example.fold ? "
Again" : ""}`; for (const day of days) { const cell = lookup.get(`${day}/${row}`); if (!cell) { body += ''; continue; } const isBlocked = blocked.has(cell.slot), stats = editor ? null : slotStats(cell.slot); const previous = slotSet.has(cell.slot - source.minutes * 60) && !blocked.has(cell.slot - source.minutes * 60) ? slotStatsSafe(cell.slot - source.minutes * 60) : null; const boundary = !editor && previous && previous.signature !== stats.signature; const peerBoundary = draft && previous && previous.peers.signature !== stats.peers.signature; const value = editor ? isBlocked ? icon("ban") : "" : isBlocked ? icon("lock-keyhole") : stats.n ? stats.individual ? stats.yes ? icon("check") : stats.maybe ? icon("minus") : "" : `${stats.yes + stats.maybe}/${stats.n}` : ""; const offset = example.fold ? new Intl.DateTimeFormat("en", { timeZone: displayZone, timeZoneName: "shortOffset", }) .formatToParts(new Date(cell.start * 1000)) .find((p) => p.type === "timeZoneName").value : ""; const label = `${timeRange(cell.start, cell.end)} ${offset}: ${isBlocked ? "Blocked" : editor ? "Available" : `${stats.yes} yes, ${stats.maybe} maybe`}`; const peerLabel = stats?.peers ? `; Other participants: ${stats.peers.yes} yes, ${stats.peers.maybe} maybe out of ${stats.peers.n}` : ""; body += ``; } body += ""; previousRow = row; } target.innerHTML = `
${days.map(() => "").join("")}${days.map((d) => ``).join("")}${body}
${esc(source.minutes)} min${dateHeading(d)}
${esc(displayZone.replaceAll("_", " "))}${days.length} date${days.length === 1 ? "" : "s"}
`; const wrapper = $(".grid-scroll", target); wrapper.scrollLeft = scroll[0]; wrapper.scrollTop = scroll[1]; wrapper.addEventListener("scroll", hidePopover, { passive: true }); target.onpointerdown = (event) => { const button = event.target.closest(".slot"); if (!button || button.disabled) return; if ((editor || draft) && paintMode !== "pan") { event.preventDefault(); painting = true; paintCell(button); } }; target.onpointermove = (event) => { if (painting) { const b = document .elementFromPoint(event.clientX, event.clientY) ?.closest(".slot"); if (b && !b.disabled) paintCell(b); } }; target.onclick = (event) => { const button = event.target.closest(".slot"); if (!button || button.disabled) return; if (!editor && !draft) showPopover(button, event.pointerType === "touch" || event.detail === 0); else if (event.detail === 0) paintCell(button); }; target.onpointerover = (event) => { const b = event.target.closest(".slot"); if (b && !editor && !draft && !b.disabled && event.pointerType === "mouse") showPopover(b); }; target.onpointerout = (event) => { if ( event.pointerType === "mouse" && !event.relatedTarget?.closest?.(".slot,#popover") ) schedulePopoverClose(); }; icons(); } function slotStatsSafe(slot) { return editor ? null : slotStats(slot); } function paintCell(button) { if (paintMode === "pan") return; const slot = +button.dataset.slot; if (editor) { if (paintMode === "block") editor.blocked.add(slot); else editor.blocked.delete(slot); editor.dirty = true; } else if (draft) { if (paintMode === "no") delete draft.votes[slot]; else draft.votes[slot] = paintMode; } $$(`[data-slot="${slot}"]`).forEach((b) => { if (editor) { b.classList.toggle("blocked", editor.blocked.has(slot)); b.innerHTML = editor.blocked.has(slot) ? icon("ban") : ""; } else { const s = slotStats(slot); b.setAttribute("style", cellStyle(s)); b.classList.toggle("stripes", !!s.maybe); b.innerHTML = `${s.yes ? icon("check") : s.maybe ? icon("minus") : ""}`; } }); icons(); } function hidePopover() { clearTimeout(hoverCloseTimer); $("#popover").hidden = true; touchPopover = false; } function schedulePopoverClose() { if (!touchPopover) { clearTimeout(hoverCloseTimer); hoverCloseTimer = setTimeout(hidePopover, 150); } } function showPopover(button, pinned = false) { clearTimeout(hoverCloseTimer); touchPopover = pinned; const cell = gridCells[+button.dataset.cell]; if (!cell) return; const popup = $("#popover"); popup.innerHTML = `

${esc(timeRange(cell.start, cell.end))}

${poll.people.length ? poll.people.map((p) => ``).join("") : '

No votes yet

'}`; popup.hidden = false; icons(); const rect = button.getBoundingClientRect(); popup.style.left = `${Math.max(12, Math.min(innerWidth - popup.offsetWidth - 12, rect.left))}px`; popup.style.top = `${Math.max(12, Math.min(innerHeight - popup.offsetHeight - 12, rect.bottom + 5))}px`; $(".close", popup).onclick = hidePopover; $$("[data-person]", popup).forEach( (b) => (b.onclick = () => selectPerson(+b.dataset.person)), ); } function renderSummaries() { const target = $("#summaries"); if (!target) return; target.innerHTML = [ ["yes", "Yes only"], ["inclusive", "Yes + Maybe"], ] .map( ([key, title]) => `

Longest overlap ${title}

${ poll.people.length ? `` : '

No participants yet

' }
`, ) .join(""); } async function refreshPoll() { if (painting) { pendingLive = true; return; } try { const latest = await api(`/polls/${code}`); if (latest.revision <= poll.revision) return; const previous = poll; poll = latest; if ( draft && (draft.schedule_revision !== poll.schedule_revision || (draft.id && poll.people.find((p) => p.id === draft.id)?.revision !== draft.revision)) ) notice( "The schedule or this participant changed elsewhere. Cancel this draft and review the latest votes before saving.", true, ); if (selected && !poll.people.some((p) => p.id === selected)) selected = null; $("#poll-title").textContent = poll.title; document.title = `${poll.title} - Timepoll`; if (poll.fixed_timezone || previous.fixed_timezone) displayZone = poll.fixed_timezone ? poll.timezone : localZone; $("#view-zone").disabled = poll.fixed_timezone; $("#view-zone").value = displayZone; renderParticipants(); if (!draft) renderActions(); if (!draft || draft.schedule_revision === poll.schedule_revision) renderGrid(); renderSummaries(); } catch (error) { notice(error.message, true); } } function subscribe() { eventSource = new EventSource(`/api/polls/${code}/events`); eventSource.onopen = () => { $("#connection").textContent = "Live"; refreshPoll(); }; eventSource.onmessage = (event) => { if (+event.data !== poll.revision) refreshPoll(); }; eventSource.onerror = () => ($("#connection").textContent = "Reconnecting..."); } document.addEventListener("pointerup", () => { if (painting) { painting = false; renderGrid(); if (pendingLive) { pendingLive = false; refreshPoll(); } } }); document.addEventListener("pointercancel", () => { painting = false; if (editor || draft) renderGrid(); }); document.addEventListener("pointerdown", (event) => { if (!event.target.closest("#popover,.slot")) hidePopover(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") hidePopover(); }); $("#popover").onpointerenter = () => clearTimeout(hoverCloseTimer); $("#popover").onpointerleave = schedulePopoverClose; document.addEventListener( "scroll", (event) => { if (!$("#popover").contains(event.target)) hidePopover(); }, true, ); window.addEventListener("resize", hidePopover); window.addEventListener("beforeunload", (event) => { if (draft || editor?.dirty) { event.preventDefault(); event.returnValue = ""; } }); dark.addEventListener("change", () => { if (poll || editor) renderGrid(); }); async function start() { if (!route.length) { home(); return; } zones = await api("/timezones"); if (route[0] === "new") { editPage(); return; } if (adminToken) { code = (await api("/admin/resolve", "POST", undefined, true)).code; } poll = await api(`/polls/${code}`); if (adminToken) { await api(`/polls/${code}/admin`, "GET", null, true); editPage(true); } else { displayZone = poll.fixed_timezone ? poll.timezone : localZone; publicPage(); subscribe(); } } start().catch((error) => { app.innerHTML = `

Unable to open poll

${esc(error.message)}

Return to Timepoll`; });