Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 3e1cb86700a5e96238d7a2f1
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Poll.tsx
// A poll: the times, who can make each one, the best time, comments, and (once the organizer picks it) the final time.
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import {
adminKey, rememberPoll, membership, rememberMembership, savedName, saveName, isNewPoll, markNewPoll, seededPoll,
} from "./lib/polls";
import { Dialog, Menu, useClosing, toast, share, copy, download, PollSkeleton } from "./ui";
import { Editor } from "./Editor";
import { SITE_URL, API_BASE } from "../functions/lib/config";
import {
parseOptions, inDayOrder, parseAnswers, tally, bestOptions, optionDay, formatDay, formatTimes, zoneLabel, zoneCity, zoneAbbr,
googleCalendarUrl, outlookCalendarUrl, fileName, randomId, ANSWER_LABEL, LIMITS,
type Answer, type Counts, type Poll, type PollOption, type Vote, type Comment,
} from "../functions/lib/when";
const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const NEXT: Record<string, Answer> = { none: "yes", yes: "maybe", maybe: "no", no: "yes" };
function useMedia(query: string) {
const [on, setOn] = useState(() => matchMedia(query).matches);
useEffect(() => {
const m = matchMedia(query);
const change = () => setOn(m.matches);
m.addEventListener("change", change);
return () => m.removeEventListener("change", change);
}, [query]);
return on;
}
function useOnline() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const up = () => setOnline(true);
const down = () => setOnline(false);
addEventListener("online", up);
addEventListener("offline", down);
return () => { removeEventListener("online", up); removeEventListener("offline", down); };
}, []);
return online;
}
const icon = {
link: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M8 12l4-4M7 9.5 5.2 11.3a2.8 2.8 0 1 0 4 4L11 13.5M13 10.5l1.8-1.8a2.8 2.8 0 1 0-4-4L9 6.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>,
pin: <svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 18s5.5-5.1 5.5-9.5a5.5 5.5 0 1 0-11 0C4.5 12.9 10 18 10 18Z" fill="none" stroke="currentColor" strokeWidth="1.5" /><circle cx="10" cy="8.5" r="2" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>,
clock: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7.25" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M10 6v4.2l2.8 1.8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>,
calendar: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><rect x="3.5" y="4.5" width="13" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M3.5 8.5h13M7 3v3M13 3v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>,
x: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>,
};
/** Opens the poll on this device: joins it (the link is the permission) and, from an admin link, becomes an owner. */
export function PollPage({ pollId, view }: { pollId: string; view: "poll" | "admin" | "edit" }) {
const [state, setState] = useState<"joining" | "ready" | "missing" | "error">("joining");
const [uid, setUid] = useState("");
const [owner, setOwner] = useState(false);
const [claimBad, setClaimBad] = useState(false);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const id = await me();
let role = membership(id, pollId);
if (!role) {
const join = `polls/${pollId}/joins/${id}`;
const [joined, owned] = await Promise.all([get(join), get(`polls/${pollId}/owners/${id}`).catch(() => null)]);
if (!joined) {
try {
await set(join, { at: serverTimestamp() });
} catch {
if (!cancelled) setState("missing");
return;
}
}
role = owned ? "owner" : "in";
rememberMembership(id, pollId, role);
}
const key = view === "admin" ? new URLSearchParams(location.hash.slice(1)).get("k") : null;
if (key) {
if (role !== "owner") {
try {
await setMany([
{ path: `pollclaims/${pollId}/c/${id}`, document: { key } },
{ path: `polls/${pollId}/owners/${id}`, document: { at: serverTimestamp() } },
]);
role = "owner";
rememberMembership(id, pollId, role);
} catch {
if (!cancelled) setClaimBad(true);
}
}
if (role === "owner") {
rememberPoll({ id: pollId, key });
toast("You can manage this poll on this device now.");
}
// Drop the key from the address bar so it is not shared by accident.
history.replaceState(null, "", `/p/${pollId}`);
window.dispatchEvent(new PopStateEvent("popstate"));
}
if (!cancelled) { setUid(id); setOwner(role === "owner"); setState("ready"); }
} catch (err) {
if (!cancelled) { setError(explain(err)); setState("error"); }
}
})();
return () => { cancelled = true; };
}, [pollId, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
if (state === "joining") return <PollSkeleton />;
if (state === "missing") return (
<div className="wrap page-pad narrow">
<div className="empty-state enter">
<img className="art" src="/art/card-when.webp" alt="" width="180" height="158" />
<h1>This poll does not exist</h1>
<p>Check the link you were sent. Poll links are long, and every character counts.</p>
<a className="btn" href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a new poll</a>
</div>
</div>
);
if (state === "error") return (
<div className="wrap page-pad narrow">
<div className="error-state enter">
<h1>The poll did not open</h1>
<p>{error}</p>
<button className="btn" type="button" onClick={() => { setState("joining"); setAttempt((a) => a + 1); }}>Try again</button>
</div>
</div>
);
if (view === "edit") return <EditPoll pollId={pollId} owner={owner} />;
return <PollView pollId={pollId} uid={uid} owner={owner} claimBad={claimBad} />;
}
function EditPoll({ pollId, owner }: { pollId: string; owner: boolean }) {
const { data: poll } = useQuery(owner ? `polls/${pollId}` : null) as { data: Poll | null | undefined };
const { data: votes } = useQuery(owner ? `polls/${pollId}/votes` : null, { limit: 1000 }) as { data: Vote[] | undefined };
const back = (
<a className="back-link" href={`/p/${pollId}`} onClick={(e) => { e.preventDefault(); navigate(`/p/${pollId}`); }}>
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4.5 7 10l5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
Back to the poll
</a>
);
if (!owner) return (
<div className="wrap page-pad narrow">
<div className="empty-state">
<h1>Only the organizer can edit this poll</h1>
<p>If you made it on another device, open your admin link on this one first.</p>
<a className="btn" href={`/p/${pollId}`} onClick={(e) => { e.preventDefault(); navigate(`/p/${pollId}`); }}>Back to the poll</a>
</div>
</div>
);
return (
<div className="wrap page-pad edit-page">
{back}
<h1 className="h1" style={{ marginTop: 12 }}>Edit poll</h1>
{poll === undefined || votes === undefined
? <span className="skeleton edit-skeleton" aria-busy="true" aria-label="Loading the poll" />
: poll
? <Editor mode="edit" pollId={pollId} poll={poll} hasVotes={votes.length > 0} />
: <p className="notice notice-error">This poll could not be loaded.</p>}
</div>
);
}
interface Head { month: string; day: string; weekday: string; start: string; end?: string }
function optionHead(o: PollOption, tz: string): Head {
const zone = o.allDay ? "UTC" : tz;
const parts = new Intl.DateTimeFormat(undefined, { timeZone: zone, month: "short", day: "numeric", weekday: "short" }).formatToParts(new Date(o.start * 1000));
const part = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
const time = (s: number) => new Intl.DateTimeFormat(undefined, { timeZone: tz, hour: "numeric", minute: "2-digit" }).format(new Date(s * 1000));
return {
month: part("month"), day: part("day"), weekday: part("weekday"),
start: o.allDay ? "All day" : time(o.start),
...(o.allDay || o.end == null ? {} : { end: time(o.end) }),
};
}
const check = <path d="M4.5 10.5 8 14l7.5-8" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />;
function Mark({ answer }: { answer?: Answer }) {
if (answer === "yes") return <span className="mark mark-yes"><svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true">{check}</svg></span>;
if (answer === "maybe") return (
<span className="mark mark-maybe">
<svg width="22" height="16" viewBox="0 0 28 20" aria-hidden="true">
<path d="M5 3.5Q1.5 10 5 16.5M23 3.5q3.5 6.5 0 13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<g transform="translate(4 0)">{check}</g>
</svg>
</span>
);
if (answer === "no") return <span className="mark mark-no"><svg width="12" height="12" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg></span>;
return <span className="mark mark-none" />;
}
/** "✓ 3 (✓) 1", with the words for screen readers. */
function Count({ c }: { c: Counts }) {
const words = `${c.yes} yes${c.maybe ? `, ${c.maybe} if need be` : ""}`;
return (
<span className="count" title={words}>
<span className={`count-yes${c.yes ? "" : " zero"}`} aria-hidden="true"><Mark answer="yes" />{c.yes}</span>
{c.maybe > 0 && <span className="count-maybe" aria-hidden="true"><Mark answer="maybe" />{c.maybe}</span>}
<span className="sr-only">{words}</span>
</span>
);
}
type Panel = { kind: "pick" } | { kind: "remove"; vote?: Vote } | { kind: "comment"; comment: Comment } | null;
function PollView({ pollId, uid, owner, claimBad }: { pollId: string; uid: string; owner: boolean; claimBad: boolean }) {
const seed = seededPoll(pollId);
const pollQ = useQuery(`polls/${pollId}`) as { data: Poll | null | undefined; error: Error | null };
const votesQ = useQuery(`polls/${pollId}/votes`, { limit: 1000 }) as { data: Vote[] | undefined; error: Error | null };
const commentsQ = useQuery(`polls/${pollId}/comments`, { limit: 500 }) as { data: Comment[] | undefined };
const poll = pollQ.data === undefined ? seed : pollQ.data;
const votesRaw = votesQ.data ?? (seed ? [] : undefined);
const narrow = useMedia("(max-width: 720px)");
const online = useOnline();
const [zone, setZone] = useState(BROWSER_TZ);
const [isNew, setIsNew] = useState(() => isNewPoll(pollId));
const [panel, setPanel] = useState<Panel>(null);
const [name, setName] = useState("");
const [draft, setDraft] = useState<Record<string, Answer>>({});
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [pending, setPending] = useState<Vote | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const firstVotes = useRef<Set<string> | null>(null);
if (!firstVotes.current && votesQ.data) firstVotes.current = new Set(votesQ.data.map((v) => v.id));
const options = useMemo(() => inDayOrder(parseOptions(poll?.options), zone), [poll?.options, zone]);
// Show a just-saved answer at once; the live copy replaces it when it arrives.
const votes = useMemo(() => {
const list = [...(votesRaw || [])];
if (pending) {
const i = list.findIndex((v) => v.id === pending.id);
if (i < 0) list.push(pending);
else if (list[i].answers !== pending.answers || list[i].name !== pending.name) list[i] = pending;
}
return list.sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0));
}, [votesRaw, pending]);
const mine = useMemo(() => [...votes].reverse().find((v) => v.by === uid), [votes, uid]);
const counts = useMemo(() => tally(options, votes), [options, votes]);
// Once a final time is picked, "Best" tags would compete with it.
const best = useMemo(() => (poll?.chosen ? [] : bestOptions(options, counts)), [options, counts, poll?.chosen]);
useEffect(() => {
if (dirty) return;
setName(mine?.name ?? savedName());
setDraft(mine ? parseAnswers(mine.answers) : {});
}, [mine?.id, mine?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!pending || !votesRaw) return;
const live = votesRaw.find((v) => v.id === pending.id);
if (live && live.answers === pending.answers && live.name === pending.name) setPending(null);
}, [votesRaw, pending]);
useEffect(() => {
if (!poll?.title) return;
const before = document.title;
document.title = `${poll.title} | When`;
if (owner) rememberPoll({ id: pollId, title: poll.title });
return () => { document.title = before; };
}, [poll?.title, owner, pollId]);
// Unsaved answers: ask before the tab closes or reloads.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; };
addEventListener("beforeunload", warn);
return () => removeEventListener("beforeunload", warn);
}, [dirty]);
if (poll === undefined || votesRaw === undefined) {
if (pollQ.error || votesQ.error) return (
<div className="wrap page-pad narrow">
<div className="error-state">
<h1>The poll did not load</h1>
<p>{online ? "The connection dropped. It reconnects on its own; you can also reload." : "You are offline. The poll loads when you reconnect."}</p>
<button className="btn" type="button" onClick={() => location.reload()}>Reload</button>
</div>
</div>
);
return <PollSkeleton />;
}
if (!poll) return <div className="wrap page-pad narrow"><p className="notice notice-error">This poll could not be loaded.</p></div>;
const url = `${SITE_URL}/p/${pollId}`;
const key = adminKey(pollId);
const adminUrl = key ? `${url}/admin#k=${key}` : null;
const chosen = options.find((o) => o.id === poll.chosen);
const open = !poll.closed;
const others = votes.filter((v) => v.id !== mine?.id);
const zoneName = zoneLabel(zone, options.filter((o) => !o.allDay).map((o) => o.start));
const isFresh = (id: string) => Boolean(firstVotes.current && !firstVotes.current.has(id));
const answer = (id: string, a: Answer) => { setDraft((d) => ({ ...d, [id]: a })); setDirty(true); setSaveError(null); };
const cycle = (id: string) => answer(id, NEXT[draft[id] ?? "none"]);
const onName = (v: string) => { setName(v); setDirty(true); setSaveError(null); };
const save = async () => {
const n = name.trim();
if (!n) {
setSaveError("Type your name first, so everyone knows who answered.");
nameRef.current?.focus();
return;
}
setSaving(true);
setSaveError(null);
try {
const answers = JSON.stringify(Object.fromEntries(options.map((o) => [o.id, draft[o.id] ?? "no"])));
const id = mine?.id ?? randomId(16);
await set(`polls/${pollId}/votes/${id}`, mine
? { name: n, answers, updatedAt: serverTimestamp() }
: { name: n, answers, by: uid, updatedAt: serverTimestamp() });
setPending({ id, name: n, answers, by: uid, updatedAt: mine?.updatedAt ?? Math.floor(Date.now() / 1000) });
saveName(n);
setDirty(false);
toast(mine ? "Your answers are updated." : "Your answers are saved.");
} catch (e) {
setSaveError(explain(e));
} finally {
setSaving(false);
}
};
const setStatus = async (next: Partial<Poll>, done: string) => {
try {
await set(`polls/${pollId}`, next);
toast(done);
} catch (e) {
toast(explain(e));
}
};
const shareLink = () => share({ title: poll.title, url }, "Link copied. Send it to the group.");
const exportCsv = () => {
download(`${fileName(poll.title)}.csv`, answersCsv(options, votes, counts, zone), "text/csv;charset=utf-8");
toast("Answers downloaded.");
};
const status = chosen ? "Final time picked" : open ? "Open poll" : "Closed poll";
const inviting = isNew && owner && open;
const showSave = open && (!mine || dirty);
return (
<div className="poll">
<header className="poll-head">
<div className="wrap poll-head-inner">
<div className="poll-title-block">
<p className="eyebrow">{status}{owner ? " · You organize it" : ""}</p>
<h1 className="poll-title">{poll.title}</h1>
<p className="poll-meta">
{poll.location && (
<span className="meta-item">
{icon.pin}
{/^https?:\/\//.test(poll.location) ? <a href={poll.location} target="_blank" rel="noopener noreferrer">{poll.location}</a> : poll.location}
</span>
)}
<span className="meta-item">{votes.length} {votes.length === 1 ? "answer" : "answers"}</span>
<span className="meta-item">{options.length} {options.length === 1 ? "time" : "times"}</span>
</p>
{poll.description && <p className="poll-desc">{poll.description}</p>}
</div>
<div className="poll-actions">
{owner && !chosen && votes.length > 0 && (
<button className="btn" type="button" onClick={() => setPanel({ kind: "pick" })}>Pick the final time</button>
)}
{!inviting && <button className="btn btn-line" type="button" onClick={shareLink}>{icon.link} Share link</button>}
{owner && (
<Menu label="Manage" items={[
{ label: "Edit poll", onSelect: () => navigate(`/p/${pollId}/edit`) },
{ label: chosen ? "Change the final time" : "Pick the final time", onSelect: () => setPanel({ kind: "pick" }), hidden: !options.length || (!chosen && votes.length > 0) },
{ label: "Copy admin link", onSelect: () => copy(adminUrl!, "Admin link copied. Keep it private."), hidden: !adminUrl },
{ label: "Download answers (CSV)", onSelect: exportCsv, hidden: !votes.length },
{ label: "Remove an answer", onSelect: () => setPanel({ kind: "remove" }), hidden: !votes.length },
"sep",
open
? { label: "Close poll", onSelect: () => setStatus({ closed: true }, "Poll closed. Answers can no longer change.") }
: { label: "Reopen poll", onSelect: () => setStatus({ closed: false, chosen: null }, chosen ? "Poll reopened. The final time is cleared." : "Poll reopened.") },
]} />
)}
</div>
</div>
</header>
<div className="wrap poll-body">
{!online && <p className="notice">You are offline. Answers and comments cannot be saved until you reconnect.</p>}
{claimBad && <p className="notice notice-error">That admin link did not work. Check that you copied all of it. You can still answer the poll.</p>}
{inviting && (
<section className="invite panel enter" aria-labelledby="invite-title">
<h2 className="invite-title" id="invite-title">Your poll is ready. Send this link to everyone you want to answer.</h2>
<div className="invite-link">
<input className="input mono" readOnly value={url} aria-label="Poll link" onFocus={(e) => e.currentTarget.select()} />
<button className="btn" type="button" onClick={shareLink}>{icon.link} Share link</button>
</div>
{adminUrl && (
<p className="hint">
Your admin link lets you manage this poll from another device. Keep it private.{" "}
<button className="link-btn" type="button" onClick={() => copy(adminUrl, "Admin link copied. Keep it private.")}>Copy admin link</button>
</p>
)}
<button className="btn btn-quiet btn-icon btn-sm invite-close" type="button" aria-label="Dismiss" onClick={() => { markNewPoll(pollId, false); setIsNew(false); }}>{icon.x}</button>
</section>
)}
{chosen && <FinalTime pollId={pollId} poll={poll} option={chosen} zone={zone} url={url} />}
{!open && !chosen && <p className="notice">This poll is closed. Answers can no longer change.</p>}
<div className="poll-tools">
{!chosen && best.length > 0 && (() => {
const o = options.find((x) => x.id === best[0])!;
const c = counts[o.id];
return (
<p className="best">
<span className="chip chip-cobalt">Best so far</span>
<strong>{formatDay(o, zone)}, {formatTimes(o, zone)}</strong>
<span className="muted">{c.yes} yes{c.maybe ? `, ${c.maybe} if need be` : ""}{best.length > 1 ? `. Tied with ${best.length - 1} more.` : ""}</span>
</p>
);
})()}
{!chosen && !best.length && options.length > 0 && !inviting && (
<p className="muted">{votes.length ? "Nobody can make any time yet." : owner ? "No answers yet. Send the link to the group." : "Nobody has answered yet."}</p>
)}
<p className="tz">
{icon.clock}
<span>Times in <strong>{zoneName}</strong></span>
{poll.timezone !== BROWSER_TZ && (
<select className="select select-inline" value={zone} onChange={(e) => setZone(e.target.value)} aria-label="Show times in">
<option value={BROWSER_TZ}>Your time zone ({zoneCity(BROWSER_TZ)})</option>
<option value={poll.timezone}>The organizer's ({zoneCity(poll.timezone)})</option>
</select>
)}
</p>
</div>
{(() => {
const saveArea = open && options.length > 0 && (showSave ? (
<div className="action-bar save-bar">
{saveError && <p className="field-error save-error" role="alert">{saveError}</p>}
<button className="btn btn-lg save-btn" type="button" onClick={save} aria-busy={saving || undefined}>
<span>{mine ? "Save changes" : "Save my answers"}</span>
</button>
</div>
) : (
<p className="saved-note"><Mark answer="yes" /> Your answers are saved. Change any of them and save again.</p>
));
return !options.length ? (
<div className="empty-state">
<img className="art" src="/art/card-when.webp" alt="" width="180" height="158" />
<h2>No times yet</h2>
<p>{owner ? "Add the times that could work, then share the link." : "The organizer has not added any times to this poll."}</p>
{owner && <button className="btn" type="button" onClick={() => navigate(`/p/${pollId}/edit`)}>Add times</button>}
</div>
) : narrow ? (
<>
<OptionList options={options} votes={votes} mine={mine} counts={counts} best={best} chosenId={chosen?.id} zone={zone}
open={open} draft={draft} onAnswer={answer} name={name} onName={onName} nameRef={nameRef} />
{saveArea}
</>
) : (
<Grid options={options} others={others} mine={mine} counts={counts} best={best} chosenId={chosen?.id} zone={zone}
open={open} draft={draft} onCycle={cycle} name={name} onName={onName} nameRef={nameRef}
owner={owner} isFresh={isFresh} onRemove={(v) => setPanel({ kind: "remove", vote: v })} footer={saveArea} />
);
})()}
<Comments pollId={pollId} uid={uid} owner={owner} comments={commentsQ.data} knownName={name.trim() || savedName()}
onDelete={(c) => setPanel({ kind: "comment", comment: c })} />
</div>
{panel?.kind === "pick" && <PickSheet pollId={pollId} options={options} counts={counts} zone={zone} current={chosen?.id} onClosed={() => setPanel(null)} />}
{panel?.kind === "remove" && <RemoveSheet pollId={pollId} votes={others.concat(mine ? [mine] : [])} preset={panel.vote} onClosed={() => setPanel(null)} />}
{panel?.kind === "comment" && <DeleteCommentSheet pollId={pollId} comment={panel.comment} onClosed={() => setPanel(null)} />}
</div>
);
}
/** The answers as a spreadsheet: one row per person, one column per time, then the totals. */
function answersCsv(options: PollOption[], votes: Vote[], counts: Record<string, Counts>, zone: string): string {
// A leading = + - @ would run as a formula in a spreadsheet; a quote mark keeps it text.
const cell = (s: string) => {
const safe = /^[=+\-@]/.test(s) ? `'${s}` : s;
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
};
const rows = [
[`Name (times in ${zoneCity(zone)} time)`, ...options.map((o) => `${formatDay(o, zone)} ${formatTimes(o, zone)}${o.allDay ? "" : ` ${zoneAbbr(zone, o.start)}`}`)],
...votes.map((v) => { const a = parseAnswers(v.answers); return [v.name, ...options.map((o) => (a[o.id] ? ANSWER_LABEL[a[o.id]] : ""))]; }),
["Yes", ...options.map((o) => String(counts[o.id].yes))],
["If need be", ...options.map((o) => String(counts[o.id].maybe))],
];
return "" + rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n";
}
type ComposerProps = {
options: PollOption[]; counts: Record<string, Counts>; best: string[]; chosenId?: string; zone: string;
open: boolean; draft: Record<string, Answer>; name: string; onName: (v: string) => void; nameRef: React.RefObject<HTMLInputElement>; mine?: Vote;
};
function Grid({ options, others, mine, counts, best, chosenId, zone, open, draft, onCycle, name, onName, nameRef, owner, isFresh, onRemove, footer }: ComposerProps & {
others: Vote[]; onCycle: (id: string) => void; owner: boolean; isFresh: (id: string) => boolean; onRemove: (v: Vote) => void; footer: ReactNode;
}) {
const colClass = (o: PollOption) => (o.id === chosenId ? "col-final" : best.includes(o.id) ? "col-best" : "");
const row = (v: Vote, you: boolean) => {
const a = parseAnswers(v.answers);
return (
<tr key={v.id} className={isFresh(v.id) ? "enter" : undefined}>
<th scope="row" className="who">
<span className="who-name">{v.name}{you && <span className="muted"> (you)</span>}</span>
{owner && !you && <button className="who-remove" type="button" onClick={() => onRemove(v)} aria-label={`Remove ${v.name}'s answers`}>{icon.x}</button>}
</th>
{options.map((o) => (
<td key={o.id} className={`cell cell-${a[o.id] ?? "none"} ${colClass(o)}`}>
<Mark answer={a[o.id]} /><span className="sr-only">{a[o.id] ? ANSWER_LABEL[a[o.id]] : "No answer"}</span>
</td>
))}
</tr>
);
};
return (
<div className="grid-block">
{open && (
<div className="grid-help">
<p>Type your name, then click each time you can make. Click again to change it.</p>
<p className="legend" aria-hidden="true"><Mark answer="yes" /> Yes <Mark answer="maybe" /> If need be <Mark answer="no" /> No</p>
</div>
)}
<div className="grid-scroll">
<table className="vote-grid">
<caption className="sr-only">Answers for each time. Times in {zoneLabel(zone)}.</caption>
<thead>
<tr>
<th className="who corner" scope="col"><span className="sr-only">Person</span></th>
{options.map((o) => {
const h = optionHead(o, zone);
return (
<th key={o.id} scope="col" className={`opt ${colClass(o)}`}>
<span className={`col-tag${o.id === chosenId ? " final" : best.includes(o.id) ? "" : " hide"}`}>{o.id === chosenId ? "Final" : "Best"}</span>
<span className="oh-month">{h.month}</span>
<span className="oh-day">{h.day}</span>
<span className="oh-wd">{h.weekday}</span>
<span className="oh-time">{h.start}</span>
<span className="oh-time oh-end">{h.end ? `to ${h.end}` : " "}</span>
<Count c={counts[o.id]} />
</th>
);
})}
</tr>
</thead>
<tbody>
{open ? (
<tr className="me-row">
<th scope="row" className="who">
<input ref={nameRef} className="input input-sm name-input" value={name} onChange={(e) => onName(e.target.value)} maxLength={LIMITS.name} placeholder="Your name" aria-label="Your name" autoComplete="name" />
</th>
{options.map((o) => {
const a = draft[o.id];
const h = optionHead(o, zone);
return (
<td key={o.id} className={`cell cell-input ${colClass(o)}`}>
<button type="button" className={`cell-btn cell-${a ?? "none"}`} onClick={() => onCycle(o.id)}
aria-label={`${h.weekday} ${h.month} ${h.day}, ${h.start}: ${a ? ANSWER_LABEL[a] : "no answer"}. Click to change.`}>
<Mark answer={a} />
</button>
</td>
);
})}
</tr>
) : mine ? row(mine, true) : null}
{others.map((v) => row(v, false))}
{!others.length && !mine && !open && (
<tr><td className="grid-empty" colSpan={options.length + 1}>Nobody answered this poll.</td></tr>
)}
</tbody>
</table>
</div>
{footer}
</div>
);
}
function OptionList({ options, votes, mine, counts, best, chosenId, zone, open, draft, onAnswer, name, onName, nameRef }: ComposerProps & {
votes: Vote[]; onAnswer: (id: string, a: Answer) => void;
}) {
const answers = votes.map((v) => ({ name: v.id === mine?.id ? "You" : v.name, a: parseAnswers(v.answers) }));
const days: [string, PollOption[]][] = [];
for (const o of options) {
const d = optionDay(o, zone);
const last = days[days.length - 1];
if (last && last[0] === d) last[1].push(o); else days.push([d, [o]]);
}
return (
<div className="m-list">
{open && (
<div className="field m-name">
<label className="label" htmlFor="my-name">Your name</label>
<input id="my-name" ref={nameRef} className="input" value={name} onChange={(e) => onName(e.target.value)} maxLength={LIMITS.name} placeholder="So people know who answered" autoComplete="name" enterKeyHint="done" />
</div>
)}
{days.map(([d, list]) => (
<section className="m-day" key={d}>
<h2 className="day-label">{formatDay(list[0], zone, undefined, "long")}</h2>
<ul className="m-options">
{list.map((o) => {
const yes = answers.filter((x) => x.a[o.id] === "yes").map((x) => x.name);
const maybe = answers.filter((x) => x.a[o.id] === "maybe").map((x) => x.name);
const tag = o.id === chosenId ? "Final" : best.includes(o.id) ? "Best" : null;
const label = `${formatDay(o, zone)}, ${formatTimes(o, zone)}`;
return (
<li key={o.id} className={o.id === chosenId ? "col-final" : best.includes(o.id) ? "col-best" : ""}>
<div className="m-top">
<span className="m-time">{formatTimes(o, zone)}</span>
{tag && <span className={`col-tag${tag === "Final" ? " final" : ""}`}>{tag}</span>}
<Count c={counts[o.id]} />
</div>
{open && (
<div className="tri" role="group" aria-label={`Your answer for ${label}`}>
{(["yes", "maybe", "no"] as Answer[]).map((a) => (
<button key={a} type="button" aria-pressed={draft[o.id] === a} className={`tri-${a}`} onClick={() => onAnswer(o.id, a)}>
{ANSWER_LABEL[a]}
</button>
))}
</div>
)}
{(yes.length > 0 || maybe.length > 0) && (
<p className="m-names">
{yes.length > 0 && <span><strong>Yes:</strong> {yes.join(", ")}</span>}
{maybe.length > 0 && <span><strong>If need be:</strong> {maybe.join(", ")}</span>}
</p>
)}
</li>
);
})}
</ul>
</section>
))}
</div>
);
}
function FinalTime({ pollId, poll, option, zone, url }: { pollId: string; poll: Poll; option: PollOption; zone: string; url: string }) {
const when = `${formatDay(option, zone, undefined, "long")}, ${option.allDay ? "all day" : `${formatTimes(option, zone)} ${zoneLabel(zone, [option.start])}`}`;
const message = `${poll.title}: ${when}.${poll.location ? ` ${poll.location}.` : ""}`;
return (
<section className="final-card card enter" aria-labelledby="final-title">
<img className="art final-art" src="/art/card-when.webp" alt="" width="120" height="105" />
<div className="final-text">
<p className="eyebrow" id="final-title">The final time</p>
<h2 className="final-day">{formatDay(option, zone, undefined, "long")}</h2>
<p className="final-time">{formatTimes(option, zone)}{option.allDay ? "" : `, ${zoneLabel(zone, [option.start])}`}</p>
{poll.location && <p className="muted">{poll.location}</p>}
<div className="row final-actions">
<Menu label={<>{icon.calendar} Add to calendar</>} className="btn" align="start" items={[
{ label: "Apple Calendar or Outlook (.ics file)", href: `${API_BASE}/polls/${pollId}/ics`, download: true },
{ label: "Google Calendar", href: googleCalendarUrl(poll, option, url) },
{ label: "Outlook.com", href: outlookCalendarUrl(poll, option, url) },
]} />
<button className="btn btn-line" type="button" onClick={() => share({ title: poll.title, text: message, url }, "Message copied. Paste it to the group.")}>Share the time</button>
</div>
</div>
</section>
);
}
function Comments({ pollId, uid, owner, comments, knownName, onDelete }: {
pollId: string; uid: string; owner: boolean; comments: Comment[] | undefined; knownName: string; onDelete: (c: Comment) => void;
}) {
const [text, setText] = useState("");
const [author, setAuthor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const firstIds = useRef<Set<string> | null>(null);
if (!firstIds.current && comments) firstIds.current = new Set(comments.map((c) => c.id));
const list = useMemo(() => [...(comments || [])].sort((a, b) => (a.at ?? 0) - (b.at ?? 0)), [comments]);
const askName = !knownName;
const stamp = (sec: number) => new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(sec * 1000));
const post = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
const said = text.trim();
const n = (askName ? author : knownName).trim();
if (!said) return setError("Write a comment first.");
if (!n) return setError("Add your name, so people know who wrote it.");
setBusy(true);
setError(null);
try {
await set(`polls/${pollId}/comments/${randomId(16)}`, { name: n.slice(0, LIMITS.name), text: said, by: uid, at: serverTimestamp() });
if (askName) saveName(n);
setText("");
} catch (err) {
setError(explain(err));
} finally {
setBusy(false);
}
};
return (
<section className="comments" aria-labelledby="comments-title">
<h2 className="comments-title" id="comments-title">Comments{list.length ? ` (${list.length})` : ""}</h2>
{comments === undefined ? (
<span className="skeleton" style={{ height: 56, borderRadius: 12 }} />
) : list.length > 0 && (
<ul className="comment-list">
{list.map((c) => (
<li key={c.id} className={firstIds.current && !firstIds.current.has(c.id) ? "enter" : undefined}>
<p className="comment-head">
<strong>{c.name}{c.by === uid && <span className="muted"> (you)</span>}</strong>
<span className="muted">{c.at ? stamp(c.at) : ""}</span>
{(c.by === uid || owner) && <button className="link-btn link-danger" type="button" onClick={() => onDelete(c)} aria-label={`Delete the comment by ${c.name}`}>Delete</button>}
</p>
<p className="comment-text">{c.text}</p>
</li>
))}
</ul>
)}
<form className="comment-form" onSubmit={post} noValidate>
{askName && (
<input className="input" value={author} onChange={(e) => setAuthor(e.target.value)} maxLength={LIMITS.name} placeholder="Your name" aria-label="Your name for the comment" autoComplete="name" />
)}
<textarea className="textarea" value={text} onChange={(e) => { setText(e.target.value); setError(null); }} maxLength={LIMITS.comment} rows={2}
placeholder={list.length ? "Write a comment" : "Ask a question or say which time suits you"} aria-label="Comment" />
{error && <p className="field-error" role="alert">{error}</p>}
<div className="comment-actions">
{askName ? <span className="hint">Everyone with the link sees comments.</span> : <span className="hint">Posting as {knownName}. Everyone with the link sees comments.</span>}
<button className="btn" type="submit" aria-busy={busy || undefined}><span>Post</span></button>
</div>
</form>
</section>
);
}
function PickSheet({ pollId, options, counts, zone, current, onClosed }: {
pollId: string; options: PollOption[]; counts: Record<string, Counts>; zone: string; current?: string; onClosed: () => void;
}) {
const sheet = useClosing(onClosed);
const ranked = [...options].sort((a, b) => counts[b.id].yes - counts[a.id].yes || counts[b.id].maybe - counts[a.id].maybe || a.start - b.start);
const [pick, setPick] = useState(current ?? ranked[0]?.id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async () => {
setBusy(true);
try {
await set(`polls/${pollId}`, { chosen: pick, closed: true });
toast("Final time picked. Everyone with the link sees it now.");
sheet.close();
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Dialog title="Pick the final time" {...sheet} footer={<>
<button className="btn btn-line" type="button" onClick={sheet.close}>Cancel</button>
<button className="btn" type="button" onClick={save} disabled={!pick} aria-busy={busy || undefined}><span>Pick this time</span></button>
</>}>
<p className="muted">Everyone with the link sees the final time and can add it to their calendar. Picking closes the poll; you can reopen it.</p>
<ul className="pick-list" role="radiogroup" aria-label="Times, most yes first">
{ranked.map((o) => (
<li key={o.id}>
<label className={`pick${pick === o.id ? " on" : ""}`}>
<input type="radio" name="pick" checked={pick === o.id} onChange={() => setPick(o.id)} />
<span className="pick-main">
<span className="pick-day">{formatDay(o, zone)}</span>
<span className="muted">{formatTimes(o, zone)}</span>
</span>
<Count c={counts[o.id]} />
</label>
</li>
))}
</ul>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</Dialog>
);
}
/** Removing someone's answers: a list to choose from, or straight to the question when a row's remove button opened it. */
function RemoveSheet({ pollId, votes, preset, onClosed }: { pollId: string; votes: Vote[]; preset?: Vote; onClosed: () => void }) {
const sheet = useClosing(onClosed);
const [target, setTarget] = useState<Vote | undefined>(preset);
const [busy, setBusy] = useState(false);
const remove = async () => {
if (!target) return;
setBusy(true);
try {
await set(`polls/${pollId}/votes/${target.id}`, null);
toast(`${target.name}'s answers are removed.`);
sheet.close();
} catch (e) {
toast(explain(e));
setBusy(false);
}
};
return (
<Dialog title={target ? `Remove ${target.name}'s answers?` : "Remove an answer"} {...sheet} footer={target ? <>
<button className="btn btn-line" type="button" onClick={() => (preset ? sheet.close() : setTarget(undefined))}>{preset ? "Cancel" : "Back"}</button>
<button className="btn" type="button" onClick={remove} aria-busy={busy || undefined}><span>Remove</span></button>
</> : undefined}>
{target ? (
<p className="muted">Their row leaves the poll for everyone. They can answer again with the link.</p>
) : (
<ul className="people-list">
{votes.map((v) => (
<li key={v.id}><span>{v.name}</span><button className="btn btn-sm btn-line btn-danger" type="button" onClick={() => setTarget(v)}>Remove</button></li>
))}
</ul>
)}
</Dialog>
);
}
function DeleteCommentSheet({ pollId, comment, onClosed }: { pollId: string; comment: Comment; onClosed: () => void }) {
const sheet = useClosing(onClosed);
const [busy, setBusy] = useState(false);
const remove = async () => {
setBusy(true);
try {
await set(`polls/${pollId}/comments/${comment.id}`, null);
toast("Comment deleted.");
sheet.close();
} catch (e) {
toast(explain(e));
setBusy(false);
}
};
return (
<Dialog title="Delete this comment?" {...sheet} footer={<>
<button className="btn btn-line" type="button" onClick={sheet.close}>Cancel</button>
<button className="btn" type="button" onClick={remove} aria-busy={busy || undefined}><span>Delete</span></button>
</>}>
<blockquote className="quote"><strong>{comment.name}:</strong> {comment.text}</blockquote>
</Dialog>
);
}
Change history
Quality pass: comments, CSV export, share the final time, calendar menu, fixes
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync