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
1018 lines
39 KiB
JavaScript
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) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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>`;
|
|
});
|