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
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
2315d84768 · 5 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