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/Editor.tsx
// Making or editing a poll: a title, a month calendar to tap dates, and the times for those dates.
// Times are wall-clock times in the poll's time zone; they are stored as absolute instants.
import { useId, useMemo, useRef, useState, type FormEvent } from "react";
import { me, explain, sdk } from "./lib/bounded";
import { navigate } from "./lib/router";
import { rememberPoll, rememberMembership, markNewPoll, seedPoll } from "./lib/polls";
import { toast } from "./ui";
import {
keepIds, stringifyOptions, parseOptions, zonedToUtc, wallClock, dateToUtcDay, utcDayToDate, zoneLabel, randomId, LIMITS,
type Poll, type PollOption,
} from "../functions/lib/when";
interface Slot { start: string; end: string }
interface DayTimes { allDay: boolean; slots: Slot[] }
const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const DEFAULT_TIMES: DayTimes = { allDay: false, slots: [{ start: "09:00", end: "10:00" }] };
const pad = (n: number) => String(n).padStart(2, "0");
const ymd = (y: number, m: number, d: number) => `${y}-${pad(m + 1)}-${pad(d)}`;
const toMinutes = (t: string) => { const [h, m] = t.split(":").map(Number); return h * 60 + m; };
const fromMinutes = (n: number) => { const v = ((n % 1440) + 1440) % 1440; return `${pad(Math.floor(v / 60))}:${pad(v % 60)}`; };
const nextDate = (date: string) => utcDayToDate(dateToUtcDay(date) + 86400);
const sameTimes = (a: DayTimes, b: DayTimes) => JSON.stringify(a) === JSON.stringify(b);
const copyTimes = (t: DayTimes): DayTimes => ({ allDay: t.allDay, slots: t.slots.map((s) => ({ ...s })) });
function localToday(): string {
const d = new Date();
return ymd(d.getFullYear(), d.getMonth(), d.getDate());
}
function dateLabel(date: string, style: "short" | "long" = "short"): string {
return new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: style, month: style, day: "numeric" }).format(new Date(dateToUtcDay(date) * 1000));
}
/** 0 for Sunday-first calendars, 1 for Monday-first. */
function weekStart(): number {
try {
const loc = new Intl.Locale(navigator.language) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };
const info = loc.getWeekInfo?.() ?? loc.weekInfo;
if (info?.firstDay) return info.firstDay % 7;
return ["US", "CA", "MX", "BR", "JP", "IL", "KR", "PH", "TW", "IN", "ZA", "HK"].includes(loc.maximize().region || "US") ? 0 : 1;
} catch {
return 0;
}
}
/** Groups stored options back into dates and times, as the organizer entered them. */
function fromOptions(options: PollOption[], tz: string) {
const perDate: Record<string, DayTimes> = {};
for (const o of options) {
if (o.allDay) {
perDate[utcDayToDate(o.start)] = { allDay: true, slots: [] };
continue;
}
const start = wallClock(o.start, tz);
const day = (perDate[start.date] ??= { allDay: false, slots: [] });
if (!day.allDay) day.slots.push({ start: start.time, end: o.end != null ? wallClock(o.end, tz).time : "" });
}
const dates = Object.keys(perDate).sort();
const first = perDate[dates[0]];
const same = dates.length > 0 && dates.every((d) => sameTimes(perDate[d], first));
return { dates, perDate, same, shared: first ? copyTimes(first) : DEFAULT_TIMES };
}
type Props = { mode: "create" } | { mode: "edit"; pollId: string; poll: Poll; hasVotes: boolean };
export function Editor(props: Props) {
const editing = props.mode === "edit" ? props : null;
const initial = useMemo(() => (editing ? fromOptions(parseOptions(editing.poll.options), editing.poll.timezone) : null), []); // eslint-disable-line react-hooks/exhaustive-deps
const [title, setTitle] = useState(editing?.poll.title ?? "");
const [description, setDescription] = useState(editing?.poll.description ?? "");
const [location, setLocation] = useState(editing?.poll.location ?? "");
const [details, setDetails] = useState(Boolean(editing?.poll.description || editing?.poll.location));
const [tz, setTz] = useState(editing?.poll.timezone ?? BROWSER_TZ);
const [pickZone, setPickZone] = useState(false);
const [dates, setDates] = useState<string[]>(initial?.dates ?? []);
const [same, setSame] = useState(initial?.same ?? true);
const [shared, setShared] = useState<DayTimes>(initial?.shared ?? DEFAULT_TIMES);
const [perDate, setPerDate] = useState<Record<string, DayTimes>>(initial?.perDate ?? {});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<{ at: "title" | "dates" | "times" | "form"; text: string } | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
const titleId = useId();
// Start the guest session on the first touch, so "Create poll" does not wait for it.
const warm = () => { if (!editing) me().catch(() => {}); };
const timesFor = (date: string) => (same ? shared : perDate[date] ?? shared);
const toggleDate = (date: string) => {
if (error?.at === "dates") setError(null);
if (dates.includes(date)) {
setDates(dates.filter((d) => d !== date));
return;
}
if (!same && !perDate[date]) {
const last = dates.length ? perDate[dates[dates.length - 1]] : null;
setPerDate({ ...perDate, [date]: copyTimes(last ?? shared) });
}
setDates([...dates, date].sort());
};
const splitPerDate = () => {
setPerDate(Object.fromEntries(dates.map((d) => [d, copyTimes(shared)])));
setSame(false);
};
const useFirstForAll = () => {
setShared(copyTimes(perDate[dates[0]] ?? shared));
setSame(true);
};
/** The options these dates and times make, in UTC. A time that ends before it starts ends the next day. */
const slots = useMemo(() => {
const out: Omit<PollOption, "id">[] = [];
for (const date of dates) {
const t = same ? shared : perDate[date] ?? shared;
if (t.allDay) { out.push({ start: dateToUtcDay(date), allDay: true }); continue; }
for (const s of t.slots) {
if (!s.start) continue;
const start = zonedToUtc(date, s.start, tz);
if (!s.end || s.end === s.start) { out.push({ start }); continue; }
const end = zonedToUtc(s.end < s.start ? nextDate(date) : date, s.end, tz);
out.push({ start, end });
}
}
return out;
}, [dates, same, shared, perDate, tz]);
const count = new Set(slots.map((s) => `${s.allDay}-${s.start}-${s.end}`)).size;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
const name = title.trim();
if (!name) {
setError({ at: "title", text: "Give the poll a title, like \"Team lunch\"." });
titleRef.current?.focus();
return;
}
if (!dates.length) return setError({ at: "dates", text: "Tap at least one date on the calendar." });
const empty = dates.find((d) => { const t = timesFor(d); return !t.allDay && !t.slots.some((s) => s.start); });
if (empty) return setError({ at: "times", text: `Add a time for ${dateLabel(empty)}, or mark it all day.` });
if (count > LIMITS.options) return setError({ at: "times", text: `A poll can have up to ${LIMITS.options} times. This one has ${count}.` });
setBusy(true);
setError(null);
const desc = description.trim();
const loc = location.trim();
try {
const { set, setMany, serverTimestamp } = await sdk();
if (editing) {
const options = keepIds(slots, parseOptions(editing.poll.options));
const chosenGone = editing.poll.chosen && !options.some((o) => o.id === editing.poll.chosen);
await set(`polls/${editing.pollId}`, {
title: name, description: desc || null, location: loc || null, timezone: tz, options: stringifyOptions(options),
...(chosenGone ? { chosen: null } : {}),
});
rememberPoll({ id: editing.pollId, title: name });
toast("Poll saved");
navigate(`/p/${editing.pollId}`);
return;
}
const uid = await me();
const pollId = randomId(20);
const key = randomId(24);
const poll = { title: name, ...(desc ? { description: desc } : {}), ...(loc ? { location: loc } : {}), timezone: tz, options: stringifyOptions(keepIds(slots, [])), closed: false };
await setMany([
{ path: `polls/${pollId}`, document: { ...poll, createdBy: uid, createdAt: serverTimestamp() } },
{ path: `polls/${pollId}/joins/${uid}`, document: { at: serverTimestamp() } },
{ path: `polls/${pollId}/owners/${uid}`, document: { at: serverTimestamp() } },
{ path: `pollkeys/${pollId}`, document: { key } },
]);
rememberPoll({ id: pollId, title: name, key, at: Date.now() });
rememberMembership(uid, pollId, "owner");
markNewPoll(pollId, true);
seedPoll(pollId, { ...poll, createdBy: uid, createdAt: Math.floor(Date.now() / 1000) });
navigate(`/p/${pollId}`);
// The landing stays mounted under the poll; leave it fresh for the next poll.
setTitle(""); setDescription(""); setLocation(""); setDates([]); setBusy(false);
} catch (err) {
setError({ at: "form", text: explain(err) });
setBusy(false);
}
};
const zones: string[] = useMemo(() => {
const list = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.("timeZone") ?? [];
return list.includes(tz) ? list : [tz, ...list];
}, [tz]);
return (
<form className="card editor" onSubmit={submit} noValidate onFocusCapture={warm} onPointerDownCapture={warm}>
<div className="editor-details">
<div className="field">
<label className="label" htmlFor={titleId}>What is it for?</label>
<input id={titleId} ref={titleRef} className="input" value={title} maxLength={LIMITS.title} placeholder="Team lunch, book club, band practice" autoComplete="off"
enterKeyHint="next" aria-invalid={error?.at === "title" || undefined} aria-describedby={error?.at === "title" ? `${titleId}-error` : undefined}
onChange={(e) => { setTitle(e.target.value); if (error?.at === "title") setError(null); }} />
{error?.at === "title" && <p className="field-error" id={`${titleId}-error`}>{error.text}</p>}
</div>
{details ? (
<>
<label className="field">
<span>Place <span className="optional">optional</span></span>
<input className="input" value={location} onChange={(e) => setLocation(e.target.value)} maxLength={LIMITS.location} placeholder="An address, a room or a video link" autoComplete="off" />
</label>
<label className="field">
<span>Note <span className="optional">optional</span></span>
<textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} maxLength={LIMITS.description} placeholder="Anything people should know" rows={2} />
</label>
</>
) : (
<button className="link-btn" type="button" onClick={() => setDetails(true)}>+ Add a place or a note</button>
)}
</div>
<div className="editor-dates">
<div className="field">
<span className="label">Dates</span>
<MonthCalendar selected={dates} onToggle={toggleDate} allowPast={Boolean(editing)} />
{error?.at === "dates" && <p className="field-error" role="alert">{error.text}</p>}
</div>
</div>
<div className="editor-times" onChange={() => { if (error?.at === "times") setError(null); }}>
<div className="times-head">
<span className="label">Times</span>
<span className="zone-note">
{pickZone ? (
<select className="select select-inline" value={tz} onChange={(e) => setTz(e.target.value)} aria-label="Time zone">
{zones.map((z) => <option key={z} value={z}>{z.replace(/_/g, " ")}</option>)}
</select>
) : (
<>In {zoneLabel(tz, slots.filter((x) => !x.allDay).map((x) => x.start))} <button className="link-btn" type="button" onClick={() => setPickZone(true)}>Change</button></>
)}
</span>
</div>
{!dates.length ? (
<p className="times-empty">Tap the dates that could work. Then set the times here.</p>
) : same ? (
<>
<TimesEditor label={dates.length > 1 ? `Same times for all ${dates.length} dates` : dateLabel(dates[0], "long")} value={shared} onChange={setShared} />
{dates.length > 1 && <button className="link-btn" type="button" onClick={splitPerDate}>Set different times for each date</button>}
</>
) : (
<>
{dates.map((d) => (
<TimesEditor key={d} label={dateLabel(d)} value={perDate[d] ?? shared} onChange={(t) => setPerDate((p) => ({ ...p, [d]: t }))} />
))}
<button className="link-btn" type="button" onClick={useFirstForAll}>Use the same times for every date</button>
</>
)}
{error?.at === "times" && <p className="field-error" role="alert">{error.text}</p>}
</div>
<div className="editor-submit">
{editing?.hasVotes && <p className="hint">People who already answered keep their answers for the times you keep. Removed times lose their answers.</p>}
{error?.at === "form" && <p className="notice notice-error" role="alert">{error.text}</p>}
<button className="btn btn-lg btn-block" type="submit" aria-busy={busy || undefined}>
<span>{editing ? "Save changes" : count ? `Create poll with ${count} ${count === 1 ? "time" : "times"}` : "Create poll"}</span>
</button>
{!editing && <p className="hint center">Free. No sign-up. Anyone with the link can answer.</p>}
</div>
</form>
);
}
function TimesEditor({ label, value, onChange }: { label: string; value: DayTimes; onChange: (t: DayTimes) => void }) {
const setSlot = (i: number, patch: Partial<Slot>) => onChange({ ...value, slots: value.slots.map((s, j) => (j === i ? { ...s, ...patch } : s)) });
const add = () => {
const last = value.slots[value.slots.length - 1];
if (!last?.start) return onChange({ ...value, slots: [...value.slots, { start: "09:00", end: "10:00" }] });
const length = last.end ? (toMinutes(last.end) - toMinutes(last.start) + 1440) % 1440 || 60 : 60;
const start = toMinutes(last.end || fromMinutes(toMinutes(last.start) + 60));
onChange({ ...value, slots: [...value.slots, { start: fromMinutes(start), end: last.end ? fromMinutes(start + length) : "" }] });
};
return (
<div className="times">
<div className="times-row-head">
<span className="times-label">{label}</span>
<label className="check check-sm">
<input type="checkbox" checked={value.allDay} onChange={(e) => onChange({ allDay: e.target.checked, slots: value.slots.length ? value.slots : DEFAULT_TIMES.slots })} />
All day
</label>
</div>
{!value.allDay && (
<>
{value.slots.map((s, i) => (
<div className="slot" key={i}>
<input className="input input-time" type="time" value={s.start} onChange={(e) => setSlot(i, { start: e.target.value })} aria-label="Start time" required />
<span className="slot-to">to</span>
<input className="input input-time" type="time" value={s.end} onChange={(e) => setSlot(i, { end: e.target.value })} aria-label="End time (optional)" />
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label="Remove this time" onClick={() => onChange({ ...value, slots: value.slots.filter((_, j) => j !== i) })} disabled={value.slots.length === 1}>
<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>
</button>
</div>
))}
<button className="link-btn" type="button" onClick={add}>+ Add a time</button>
</>
)}
</div>
);
}
function MonthCalendar({ selected, onToggle, allowPast }: { selected: string[]; onToggle: (date: string) => void; allowPast: boolean }) {
const today = localToday();
const startOn = useMemo(weekStart, []);
const [view, setView] = useState(() => {
const [y, m] = (selected[0] ?? today).split("-").map(Number);
return { y, m: m - 1 };
});
const [ty, tm] = today.split("-").map(Number);
const atFirst = !allowPast && view.y === ty && view.m === tm - 1;
const move = (delta: number) => setView(({ y, m }) => { const n = y * 12 + m + delta; return { y: Math.floor(n / 12), m: n % 12 }; });
const firstWeekday = new Date(Date.UTC(view.y, view.m, 1)).getUTCDay();
const blanks = (firstWeekday - startOn + 7) % 7;
const days = new Date(Date.UTC(view.y, view.m + 1, 0)).getUTCDate();
const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(Date.UTC(2026, 9, 18 + startOn + i))));
const monthName = new Intl.DateTimeFormat(undefined, { timeZone: "UTC", month: "long", year: "numeric" }).format(new Date(Date.UTC(view.y, view.m, 1)));
return (
<div className="cal">
<div className="cal-head">
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={() => move(-1)} disabled={atFirst} aria-label="Previous month">
<svg width="18" height="18" 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>
</button>
<span className="cal-month" aria-live="polite">{monthName}</span>
<button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={() => move(1)} aria-label="Next month">
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M7.5 4.5 13 10l-5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<div className="cal-grid" role="group" aria-label={`${monthName}: tap the dates that could work`}>
{weekdays.map((w, i) => <span className="cal-wd" key={i} aria-hidden="true">{w}</span>)}
{Array.from({ length: blanks }, (_, i) => <span key={`b${i}`} />)}
{Array.from({ length: days }, (_, i) => {
const date = ymd(view.y, view.m, i + 1);
const on = selected.includes(date);
const past = date < today;
return (
<button key={date} type="button" className={`cal-day${on ? " on" : ""}${date === today ? " today" : ""}`}
aria-pressed={on} aria-label={dateLabel(date, "long")} disabled={past && !on && !allowPast} onClick={() => onToggle(date)}>
{i + 1}
</button>
);
})}
</div>
{selected.length > 0 && (
<p className="cal-count">{selected.length} {selected.length === 1 ? "date" : "dates"} picked. Tap again to remove.</p>
)}
</div>
);
}
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