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.
functions/lib/when.ts
// When's shared logic for the web app and the public API: poll options, answers, the best time,
// time zones and calendar files. A timed option is an absolute instant (UTC seconds), so every
// viewer can see it in their own zone. An all-day option is a calendar date, the same everywhere:
// it is stored as midnight UTC of that date and always read back in UTC.
export type Answer = "yes" | "maybe" | "no";
export const ANSWERS: Answer[] = ["yes", "maybe", "no"];
export const ANSWER_LABEL: Record<Answer, string> = { yes: "Yes", maybe: "If need be", no: "No" };
export interface PollOption { id: string; start: number; end?: number; allDay?: boolean }
export interface Poll {
title: string;
description?: string | null;
location?: string | null;
timezone: string;
options: string;
closed: boolean;
chosen?: string | null;
createdBy: string;
createdAt: number;
}
export interface Vote { id: string; name: string; answers: string; by: string; updatedAt: number }
export interface Comment { id: string; name: string; text: string; by: string; at: number }
export interface Counts { yes: number; maybe: number; no: number }
export const LIMITS = { title: 120, description: 2000, location: 200, name: 60, options: 500, comment: 1000 };
const byStart = (a: PollOption, b: PollOption) => a.start - b.start || Number(Boolean(b.allDay)) - Number(Boolean(a.allDay)) || (a.end ?? 0) - (b.end ?? 0);
/** The stored options JSON, cleaned and sorted by time. */
export function parseOptions(raw: string | null | undefined): PollOption[] {
try {
const list = JSON.parse(raw || "[]");
if (!Array.isArray(list)) return [];
return list
.filter((o) => o && typeof o.id === "string" && Number.isFinite(o.start))
.map((o): PollOption => ({ id: o.id, start: o.start, ...(Number.isFinite(o.end) ? { end: o.end } : {}), ...(o.allDay ? { allDay: true } : {}) }))
.sort(byStart);
} catch {
return [];
}
}
export function stringifyOptions(options: PollOption[]): string {
return JSON.stringify([...options].sort(byStart));
}
/** Same time slot, regardless of id: used to keep ids (and answers) when an organizer edits. */
export function slotKey(o: Omit<PollOption, "id">): string {
return o.allDay ? `d${o.start}` : `t${o.start}-${o.end ?? ""}`;
}
/** Gives new slots the id of the matching existing option, so answers survive an edit. */
export function keepIds(slots: Omit<PollOption, "id">[], existing: PollOption[]): PollOption[] {
const old = new Map(existing.map((o) => [slotKey(o), o.id]));
const seen = new Set<string>();
const out: PollOption[] = [];
for (const s of slots) {
const key = slotKey(s);
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...s, id: old.get(key) ?? randomId(6) });
}
return out.sort(byStart);
}
export function parseAnswers(raw: string | null | undefined): Record<string, Answer> {
try {
const parsed = JSON.parse(raw || "{}");
const out: Record<string, Answer> = {};
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) if (ANSWERS.includes(v as Answer)) out[k] = v as Answer;
}
return out;
} catch {
return {};
}
}
/** Counts per option. A time added after someone answered counts for nobody until they answer it. */
export function tally(options: PollOption[], votes: Pick<Vote, "answers">[]): Record<string, Counts> {
const out: Record<string, Counts> = Object.fromEntries(options.map((o) => [o.id, { yes: 0, maybe: 0, no: 0 }]));
for (const v of votes) {
const a = parseAnswers(v.answers);
for (const o of options) if (a[o.id]) out[o.id][a[o.id]] += 1;
}
return out;
}
/** The option ids with the most yes, then the most if-need-be. Empty until someone can make a time. */
export function bestOptions(options: PollOption[], counts: Record<string, Counts>): string[] {
let top = { yes: 0, maybe: 0 };
for (const o of options) {
const c = counts[o.id];
if (c.yes > top.yes || (c.yes === top.yes && c.maybe > top.maybe)) top = { yes: c.yes, maybe: c.maybe };
}
if (!top.yes && !top.maybe) return [];
return options.filter((o) => counts[o.id].yes === top.yes && counts[o.id].maybe === top.maybe).map((o) => o.id);
}
/* ---------- Time zones ---------- */
const partsFormat = new Map<string, Intl.DateTimeFormat>();
function partsIn(ms: number, tz: string) {
let f = partsFormat.get(tz);
if (!f) {
f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
partsFormat.set(tz, f);
}
const p: Record<string, string> = {};
for (const x of f.formatToParts(new Date(ms))) p[x.type] = x.value;
return { y: +p.year, mo: +p.month, d: +p.day, h: +p.hour, mi: +p.minute, s: +p.second };
}
export function validTimeZone(tz: unknown): tz is string {
if (typeof tz !== "string" || !tz || tz.length > 64) return false;
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}
/** The zone's offset from UTC at that instant, in minutes (New York in summer is -240). */
export function offsetMinutes(sec: number, tz: string): number {
const ms = Math.floor(sec) * 1000;
const p = partsIn(ms, tz);
return Math.round((Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s) - ms) / 60000);
}
/** A wall-clock date and time in a zone ("2026-10-21", "15:00") as UTC seconds. */
export function zonedToUtc(date: string, time: string, tz: string): number {
const [y, mo, d] = date.split("-").map(Number);
const [h, mi] = time.split(":").map(Number);
const wall = Date.UTC(y, mo - 1, d, h, mi) / 1000;
const first = wall - offsetMinutes(wall, tz) * 60;
return wall - offsetMinutes(first, tz) * 60;
}
/** UTC seconds as the wall-clock date and time in a zone. */
export function wallClock(sec: number, tz: string): { date: string; time: string } {
const p = partsIn(sec * 1000, tz);
const two = (n: number) => String(n).padStart(2, "0");
return { date: `${p.y}-${two(p.mo)}-${two(p.d)}`, time: `${two(p.h)}:${two(p.mi)}` };
}
/** "2026-10-21T15:00:00-04:00": the instant written in the zone's local time. */
export function isoInZone(sec: number, tz: string): string {
const { date, time } = wallClock(sec, tz);
const off = offsetMinutes(sec, tz);
const sign = off < 0 ? "-" : "+";
const abs = Math.abs(off);
return `${date}T${time}:00${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
}
export function dateToUtcDay(date: string): number {
const [y, mo, d] = date.split("-").map(Number);
return Date.UTC(y, mo - 1, d) / 1000;
}
export function utcDayToDate(sec: number): string {
return new Date(sec * 1000).toISOString().slice(0, 10);
}
/** The calendar day an option falls on for a viewer in this zone. */
export function optionDay(o: PollOption, tz: string): string {
return o.allDay ? utcDayToDate(o.start) : wallClock(o.start, tz).date;
}
/** Options in reading order for a viewer in this zone: by calendar day, all-day first, then by time.
* All-day options start at midnight UTC, which is the evening before west of UTC, so start order alone misplaces them. */
export function inDayOrder(options: PollOption[], tz: string): PollOption[] {
return [...options].sort((a, b) => optionDay(a, tz).localeCompare(optionDay(b, tz)) || Number(Boolean(b.allDay)) - Number(Boolean(a.allDay)) || a.start - b.start);
}
/** "New York" from "America/New_York". */
export function zoneCity(tz: string): string {
return tz === "UTC" || tz === "Etc/UTC" ? "UTC" : (tz.split("/").pop() || tz).replace(/_/g, " ");
}
/** "EDT", or "GMT+2" where there is no common abbreviation. */
export function zoneAbbr(tz: string, sec = Date.now() / 1000, locale = "en-US"): string {
try {
const part = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: "short" }).formatToParts(new Date(sec * 1000)).find((p) => p.type === "timeZoneName");
return part?.value || tz;
} catch {
return tz;
}
}
/** "New York time (EST)" when the given instants share one abbreviation, else "New York time" (they straddle a clock change). */
export function zoneLabel(tz: string, at: number[] = [], locale?: string): string {
const city = zoneCity(tz);
if (city === "UTC") return "UTC";
const abbrs = new Set((at.length ? at : [Date.now() / 1000]).map((s) => zoneAbbr(tz, s, locale)));
return abbrs.size === 1 ? `${city} time (${[...abbrs][0]})` : `${city} time`;
}
/* ---------- Formatting ---------- */
const fmtCache = new Map<string, Intl.DateTimeFormat>();
function fmt(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale}|${JSON.stringify(opts)}`;
let f = fmtCache.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, opts);
fmtCache.set(key, f);
}
return f;
}
export function formatTime(sec: number, tz: string, locale?: string): string {
return fmt(locale, { timeZone: tz, hour: "numeric", minute: "2-digit" }).format(new Date(sec * 1000));
}
/** "Wed, Oct 21" (with the year when it is not this year). */
export function formatDay(o: PollOption, tz: string, locale?: string, style: "short" | "long" = "short"): string {
const thisYear = optionDay(o, tz).startsWith(String(new Date().getFullYear()));
return fmt(locale, { timeZone: o.allDay ? "UTC" : tz, weekday: style, month: style, day: "numeric", ...(thisYear ? {} : { year: "numeric" }) }).format(new Date(o.start * 1000));
}
/** "3:00 PM to 4:00 PM", "3:00 PM", or "All day". */
export function formatTimes(o: PollOption, tz: string, locale?: string): string {
if (o.allDay) return "All day";
const start = formatTime(o.start, tz, locale);
if (o.end == null) return start;
const endDay = wallClock(o.end, tz).date !== wallClock(o.start, tz).date;
return `${start} to ${formatTime(o.end, tz, locale)}${endDay ? " (next day)" : ""}`;
}
/** "Wed, Oct 21, 3:00 PM to 4:00 PM EDT" or "Thu, Oct 22, all day". */
export function optionLabel(o: PollOption, tz: string, locale = "en-US"): string {
if (o.allDay) return `${formatDay(o, tz, locale)}, all day`;
return `${formatDay(o, tz, locale)}, ${formatTimes(o, tz, locale)} ${zoneAbbr(tz, o.start, locale)}`;
}
/* ---------- Calendar ---------- */
const DEFAULT_LENGTH = 3600; // a timed option with no end goes into calendars as one hour
function icsStamp(sec: number): string {
return new Date(sec * 1000).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function icsDate(sec: number): string {
return utcDayToDate(sec).replace(/-/g, "");
}
function icsText(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
}
/** Folds a content line at 75 octets, as calendar files require. */
function fold(line: string): string {
const enc = new TextEncoder();
const out: string[] = [];
let cur = "";
let bytes = 0;
for (const ch of line) {
const n = enc.encode(ch).length;
if (bytes + n > (out.length ? 74 : 75)) {
out.push(cur);
cur = "";
bytes = 0;
}
cur += ch;
bytes += n;
}
out.push(cur);
return out.join("\r\n ");
}
/** The [start, end) of an option as a calendar event: all-day ends the next day; no end means one hour. */
function eventSpan(o: PollOption): { start: number; end: number } {
if (o.allDay) return { start: o.start, end: o.start + 86400 };
return { start: o.start, end: o.end ?? o.start + DEFAULT_LENGTH };
}
export function icsFile(pollId: string, poll: Pick<Poll, "title" | "description" | "location">, o: PollOption, url: string, now = Date.now() / 1000): string {
const span = eventSpan(o);
const description = [poll.description?.trim(), `Scheduled with When: ${url}`].filter(Boolean).join("\n\n");
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//OpenApps//When//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${pollId}-${o.id}@when-free.openapps.xyz`,
`DTSTAMP:${icsStamp(now)}`,
o.allDay ? `DTSTART;VALUE=DATE:${icsDate(span.start)}` : `DTSTART:${icsStamp(span.start)}`,
o.allDay ? `DTEND;VALUE=DATE:${icsDate(span.end)}` : `DTEND:${icsStamp(span.end)}`,
`SUMMARY:${icsText(poll.title)}`,
`DESCRIPTION:${icsText(description)}`,
...(poll.location?.trim() ? [`LOCATION:${icsText(poll.location.trim())}`] : []),
`URL:${url}`,
"END:VEVENT",
"END:VCALENDAR",
];
return lines.map(fold).join("\r\n") + "\r\n";
}
export function googleCalendarUrl(poll: Pick<Poll, "title" | "description" | "location">, o: PollOption, url: string): string {
const span = eventSpan(o);
const dates = o.allDay ? `${icsDate(span.start)}/${icsDate(span.end)}` : `${icsStamp(span.start)}/${icsStamp(span.end)}`;
const q = new URLSearchParams({ action: "TEMPLATE", text: poll.title, dates, details: [poll.description?.trim(), url].filter(Boolean).join("\n\n") });
if (poll.location?.trim()) q.set("location", poll.location.trim());
return `https://calendar.google.com/calendar/render?${q}`;
}
/** A link that opens a new Outlook.com event with the time filled in. */
export function outlookCalendarUrl(poll: Pick<Poll, "title" | "description" | "location">, o: PollOption, url: string): string {
const span = eventSpan(o);
const iso = (sec: number) => new Date(sec * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
const q = new URLSearchParams({
path: "/calendar/action/compose", rru: "addevent", subject: poll.title,
startdt: o.allDay ? utcDayToDate(span.start) : iso(span.start),
enddt: o.allDay ? utcDayToDate(span.end) : iso(span.end),
body: [poll.description?.trim(), url].filter(Boolean).join("\n\n"),
});
if (o.allDay) q.set("allday", "true");
if (poll.location?.trim()) q.set("location", poll.location.trim());
return `https://outlook.live.com/calendar/0/deeplink/compose?${q}`;
}
/** A filename-safe version of a title. */
export function fileName(title: string): string {
return (title.trim().replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "") || "event").slice(0, 60);
}
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
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