when-free.openapps.xyz

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.
Owned app · rev 2315d847681e
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.
← All files
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
2315d84768 · 4 hours ago
Bounded source sync
145324066b · 1 day ago
Bounded source sync
ad9b674dac · 1 day ago
Bounded source sync
e5b69608f3 · 1 day ago
Bounded source sync
a1685914b0 · 1 day ago
Bounded source sync
7108f42d4d · 1 day ago
Bounded source sync
ffb51b672e · 1 day ago
Bounded source sync
23b5d0c53b · 1 day ago