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/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
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