Files
ajp_anton 4cc2e8fc80
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
Add Timepoll availability polling application and image
2026-09-09 19:39:40 +00:00

1018 lines
39 KiB
JavaScript

/* 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
c
],
);
const icon = (name) => `<i data-lucide="${name}" aria-hidden="true"></i>`;
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)}<button title="Dismiss" aria-label="Dismiss">${icon("x")}</button>`;
$("#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 `<span class="code">${grouped(value)
.split("")
.map((c) => (/\d/.test(c) ? `<span class="digit">${c}</span>` : esc(c)))
.join("")}</span>`;
}
function zoneOptions(current) {
return zones
.map(
(z) =>
`<option value="${esc(z)}" ${z === current ? "selected" : ""}>${esc(z.replaceAll("_", " "))}</option>`,
)
.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))}<small>${esc(shortDate(day))}</small>`;
}
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 = `<div class="home"><h1>Timepoll</h1><div class="home-actions"><section><h2>Create a poll</h2><a href="/new" class="primary button-link">${icon("calendar-plus")}Create poll</a></section><section><h2>Join a poll</h2><form id="join"><label>Event code<input name="code" placeholder="ABC-123-XYZ" required autocomplete="off" maxlength="30" spellcheck="false"></label><button type="submit">${icon("arrow-right")}Join</button></form></section></div></div>`;
$("#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 `<option ${n === selected ? "selected" : ""}>${value}</option>`;
},
).join("");
}
}
function renderDates() {
$("#date-count").textContent = `${editor.dates.size} added`;
$("#dates").innerHTML =
[...editor.dates]
.sort()
.map(
(d) =>
`<button type="button" class="date-chip" data-date="${esc(d)}" title="Remove ${esc(d)}">${esc(shortDate(d))}${icon("x")}</button>`,
)
.join("") || '<span class="muted">No dates added</span>';
$$("#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 = `<div class="editor"><div class="heading"><h1>${existing ? "Edit poll" : "Create a poll"}</h1>${existing ? `<a href="/${grouped(code)}">Public poll</a>` : ""}</div>
<form id="editor-form"><section class="band"><div class="fields"><label class="wide">Event name<input id="event-title" required maxlength="120" value="${esc(poll?.title || "")}" autofocus></label><label>Timezone<select id="event-zone">${zoneOptions(displayZone)}</select></label><label>Slot length<select id="slot-size">${[15, 30, 60].map((m) => `<option value="${m}" ${m === editor.minutes ? "selected" : ""}>${m} minutes</option>`).join("")}</select></label></div><p><label class="check"><input type="checkbox" id="fixed-zone" ${poll?.fixed_timezone ? "checked" : ""}>Use this timezone for everyone</label></p></section>
<section class="band"><h2>Dates and times</h2><div class="date-entry"><div class="date-range-entry"><label>From date<input type="date" id="date-from"></label><label>Through date<input type="date" id="date-to"></label><button type="button" id="add-range" class="primary" disabled>${icon("plus")}Add dates</button></div><div class="single-date-entry"><label>Individual date<input type="date" id="single-date"></label><button type="button" id="add-date" title="Add individual date" disabled>${icon("plus")}Add date</button></div></div><fieldset class="date-bucket"><legend>Dates in this poll <span id="date-count" class="muted"></span></legend><div id="dates" class="dates" aria-live="polite"></div></fieldset><div class="toolbar"><label>From<select id="start-time"></select></label><label>Until<select id="end-time"></select></label><button type="button" id="apply-range">${icon("calendar-days")}Apply dates and times</button></div></section>
<section class="band"><div class="row between"><h2>Unavailable times <span class="muted">(optional)</span></h2><div class="segmented" id="blackout-mode"><button type="button" data-mode="block" aria-pressed="true">${icon("ban")}Block</button><button type="button" data-mode="clear" aria-pressed="false">${icon("eraser")}Allow</button><button type="button" data-mode="pan" aria-pressed="false" title="Pan grid" aria-label="Pan grid">${icon("hand")}</button></div></div><div id="grid"></div></section><div class="row between"><button type="button" id="cancel-editor">Cancel</button><button type="submit" class="primary">${icon("check")}${existing ? "Save changes" : "Create poll"}</button></div></form></div>`;
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 = `<div class="links"><h1>Your poll is ready</h1><section><h2>Private admin link</h2><p class="muted">Keep this link somewhere safe. Anyone with it can change the poll.</p><div class="link-row"><input readonly aria-label="Private admin link" value="${esc(privateLink)}"><button class="icon-button" title="Copy private link" aria-label="Copy private link">${icon("copy")}</button></div></section><section><h2>Public voting link</h2><div class="link-row"><input readonly aria-label="Public voting link" value="${esc(publicLink)}"><button class="icon-button" title="Copy public link" aria-label="Copy public link">${icon("copy")}</button></div><p>Event code: ${codeHtml(result.code)}</p><a class="primary" href="${esc(publicLink)}">Open poll ${icon("arrow-right")}</a></section></div>`;
$$(".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 = `<div class="heading"><div><h1 id="poll-title">${esc(poll.title)}</h1><div class="row">${codeHtml(poll.code)}<button id="share" class="icon-button" title="Copy public link" aria-label="Copy public link">${icon("copy")}</button></div></div><label>Timezone<select id="view-zone" ${poll.fixed_timezone ? "disabled" : ""}>${zoneOptions(displayZone)}</select></label></div><div id="participants"></div><div id="vote-actions"></div><div id="grid"></div><div class="grid-foot"><div class="legend"><span><i class="swatch"></i>Available</span><span><i class="swatch blue"></i>Everyone</span><span><i class="swatch maybe"></i>Includes maybe</span><span><i class="swatch gray"></i>Individual</span></div><span id="participant-count"></span></div><section id="summaries" class="summary-grid"></section>`;
$("#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 =
`<div class="people"><button class="person ${selected === null ? "active" : ""}" id="everyone" ${draft ? "disabled" : ""}>Everyone</button>${poll.people.map((p) => `<button class="person ${selected === p.id ? "filtered" : ""}" data-person="${p.id}" ${draft ? "disabled" : ""}>${esc(p.name)}</button>`).join("")}</div>`;
$("#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 =
`<div class="editbar"><strong>${esc(draft.name)}</strong><div class="segmented" id="vote-mode">${[
["yes", "check", "Yes"],
["maybe", "minus", "Maybe"],
["no", "eraser", "Unavailable"],
["pan", "hand", ""],
]
.map(
([mode, symbol, label]) =>
`<button type="button" data-mode="${mode}" ${mode === "pan" ? 'title="Pan grid" aria-label="Pan grid"' : ""} aria-pressed="${paintMode === mode}">${icon(symbol)}${label}</button>`,
)
.join(
"",
)}</div><div class="save-actions"><button id="cancel-votes">Cancel</button><button class="primary" id="save-votes">${icon("check")}Save votes</button></div></div>`;
$("#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 =
`<div class="editbar"><strong>${esc(person.name)}</strong><button id="edit-person">${icon("pencil")}Edit votes</button><button id="delete-person" class="danger">${icon("trash-2")}Remove participant</button></div>`;
$("#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 =
`<form class="new-person" id="new-voter"><label>Your name<input id="new-name" value="${esc(previous)}" required maxlength="80" autocomplete="name"></label><button class="primary" type="submit">${icon("plus")}Add availability</button></form>`;
$("#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 = '<p class="muted">No time slots selected</p>';
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 += `<tr class="gap" aria-hidden="true"><th class="time"></th>${days.map(() => "<td></td>").join("")}</tr>`;
}
const hourMark = source.minutes < 60 && example.minute % 60 === 0;
body += `<tr class="${hourMark ? "hour-mark" : ""}"><th class="time" scope="row">${example.clock}${example.fold ? "<br><small>Again</small>" : ""}</th>`;
for (const day of days) {
const cell = lookup.get(`${day}/${row}`);
if (!cell) {
body += '<td class="empty"></td>';
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 += `<td><button type="button" class="slot ${draft && !isBlocked ? "draft-slot" : ""} ${stats?.peers?.maybe ? "peer-stripes" : ""} ${peerBoundary ? "peer-boundary" : ""} ${isBlocked ? "blocked" : ""} ${stats?.maybe ? "stripes" : ""} ${boundary ? "boundary" : ""}" data-cell="${index.get(cell)}" data-slot="${cell.slot}" style="${stats ? cellStyle(stats) : ""}" aria-label="${esc(label + peerLabel)}" ${isBlocked && !editor ? "disabled" : ""}><span class="vote-mark">${value}</span></button></td>`;
}
body += "</tr>";
previousRow = row;
}
target.innerHTML = `<div class="grid-scroll"><table class="grid ${source.minutes === 15 ? "compact" : ""} ${(editor || draft) && paintMode !== "pan" ? "editing" : ""}" style="--columns:${days.length}" aria-label="${editor ? "Unavailable times" : "Availability"}"><colgroup><col class="time-column">${days.map(() => "<col>").join("")}</colgroup><thead><tr><th class="time" scope="col">${esc(source.minutes)} min</th>${days.map((d) => `<th scope="col">${dateHeading(d)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div><div class="grid-foot"><span>${esc(displayZone.replaceAll("_", " "))}</span><span>${days.length} date${days.length === 1 ? "" : "s"}</span></div>`;
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 = `<span class="vote-mark">${s.yes ? icon("check") : s.maybe ? icon("minus") : ""}</span>`;
}
});
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 = `<button class="icon-button close" aria-label="Close" title="Close">${icon("x")}</button><h3>${esc(timeRange(cell.start, cell.end))}</h3>${poll.people.length ? poll.people.map((p) => `<button class="voter ${p.votes[cell.slot] ? "" : "absent"}" data-person="${p.id}"><span class="name">${esc(p.name)}</span><small>${p.votes[cell.slot] === "yes" ? "Yes" : p.votes[cell.slot] === "maybe" ? "Maybe" : "No"}</small></button>`).join("") : '<p class="muted">No votes yet</p>'}`;
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]) =>
`<div><h2>Longest overlap <span class="muted">${title}</span></h2>${
poll.people.length
? `<ul class="summary-list">${poll.summaries[key]
.map((group) => {
const label = group.missing
? `All but ${group.missing}`
: "Everyone";
const describe = (stretch) =>
`<div>${esc(timeRange(stretch.start, stretch.end))}</div><div class="muted">${esc(stretch.people.map((id) => poll.people.find((p) => p.id === id)?.name).join(", "))}</div>`;
return `<li><strong>${label}${group.seconds ? ` · ${group.seconds / 60} min` : ""}</strong>${group.stretches.length ? describe(group.stretches[0]) : '<span class="muted">No common time</span>'}${group.stretches.length > 1 ? `<details><summary>${group.stretches.length - 1} other equally long stretch${group.stretches.length === 2 ? "" : "es"}</summary>${group.stretches.slice(1).map(describe).join("")}</details>` : ""}</li>`;
})
.join("")}</ul>`
: '<p class="muted">No participants yet</p>'
}</div>`,
)
.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 = `<h1>Unable to open poll</h1><p>${esc(error.message)}</p><a href="/">Return to Timepoll</a>`;
});