book-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 f8f1b3a80195
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 7385a6c1c47153b1728160ff
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/book.ts
// Book's shared logic for the web app and the public API: weekly hours, open times, time zones,
// meeting links and calendar files. Every time is an absolute instant in UTC seconds, so each
// person sees it in their own zone. A booking holds its time (and the buffer after it) as
// 15-minute cells under the page; a cell can be created only once, which is what makes a double
// booking impossible in the policy, whatever the meeting length.

export type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export const WEEKDAYS: Weekday[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
export const WEEKDAY_SHORT: Record<Weekday, string> = { sun: "Sun", mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat" };

/** Open hours per weekday, as [start, end) in minutes from midnight, in the page's time zone. */
export type Hours = Record<Weekday, [number, number][]>;
export interface Question { label: string; required: boolean }
export interface Answer { q: string; a: string }

export interface Page {
  name: string;
  title: string;
  description: string;
  durationMin: number;
  timezone: string;
  hours: string;
  /** JSON list of [from, to] dates (inclusive, in the page's time zone) when the page takes no bookings. Absent on older pages. */
  daysOff?: string | null;
  noticeHours: number;
  horizonDays: number;
  bufferMin: number;
  questions: string;
  meeting: string;
  createdBy: string;
  createdAt: number;
}

export interface Booking {
  id?: string;
  start: number;
  end: number;
  heldUntil: number;
  name: string;
  email: string;
  note: string;
  answers: string;
  status: "booked" | "cancelled";
  cancelledBy?: "owner" | "guest" | null;
  cancelledAt?: number | null;
  /** Set when the guest moved the booking: the id of the booking that replaced it. */
  movedTo?: string | null;
  by: string;
  createdAt: number;
}

export const CELL = 900;
export const DURATIONS = [15, 30, 45, 60];
export const BUFFERS = [0, 15, 30, 45, 60];
export const LIMITS = { name: 80, title: 120, description: 1000, email: 254, note: 1000, answer: 500, questions: 5, question: 200, url: 500, horizonDays: 365, noticeHours: 720, daysOff: 50 };
export const DEFAULTS = { durationMin: 30, noticeHours: 4, horizonDays: 60, bufferMin: 0 };
export const DEFAULT_HOURS: Hours = { sun: [], mon: [[540, 1020]], tue: [[540, 1020]], wed: [[540, 1020]], thu: [[540, 1020]], fri: [[540, 1020]], sat: [] };

/* ---------- Weekly hours ---------- */

const pad = (n: number) => String(n).padStart(2, "0");
export const hhmm = (min: number) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}`;

function readTime(s: string, field: string): number {
  const m = s.trim().match(/^(\d{1,2}):(\d{2})$/);
  const min = m ? Number(m[1]) * 60 + Number(m[2]) : NaN;
  if (!m || Number(m[2]) > 59 || min > 1440) throw new Error(`${field}: "${s}" is not a time like "09:00".`);
  if (min % 15) throw new Error(`${field}: times go in 15-minute steps, like 09:00, 09:15, 09:30 or 09:45.`);
  return min;
}

/** Sorts and merges ranges that touch or overlap. */
function tidy(ranges: [number, number][]): [number, number][] {
  const out: [number, number][] = [];
  for (const r of [...ranges].sort((a, b) => a[0] - b[0])) {
    const last = out[out.length - 1];
    if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
    else out.push([r[0], r[1]]);
  }
  return out;
}

const DAY_KEYS: Record<string, Weekday[]> = {
  ...Object.fromEntries(WEEKDAYS.map((d) => [d, [d]])),
  sunday: ["sun"], monday: ["mon"], tuesday: ["tue"], wednesday: ["wed"], thursday: ["thu"], friday: ["fri"], saturday: ["sat"],
  weekdays: ["mon", "tue", "wed", "thu", "fri"], weekend: ["sat", "sun"], weekends: ["sat", "sun"], everyday: [...WEEKDAYS], daily: [...WEEKDAYS],
};

/**
 * Reads hours a person or an assistant wrote: {"mon": ["09:00-12:00", "13:00-17:00"], "weekdays": "09:00-17:00"}.
 * A day maps to a list of "HH:MM-HH:MM" ranges, one string with ranges separated by commas, or [] for closed.
 * Throws a sentence for anything it cannot read.
 */
export function readHours(input: unknown): Hours {
  if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`hours must be an object like {"weekdays": "09:00-17:00"}.`);
  const out: Hours = { sun: [], mon: [], tue: [], wed: [], thu: [], fri: [], sat: [] };
  for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
    const days = DAY_KEYS[key.trim().toLowerCase()];
    if (!days) throw new Error(`hours: "${key}" is not a weekday. Use mon, tue, wed, thu, fri, sat, sun, weekdays or everyday.`);
    const list = typeof value === "string" ? value.split(",").filter((s) => s.trim()) : Array.isArray(value) ? value : null;
    if (!list) throw new Error(`hours.${key} must be a list of ranges like ["09:00-17:00"].`);
    const ranges = list.map((r): [number, number] => {
      const parts = Array.isArray(r) ? r.map(String) : typeof r === "string" ? r.split(/\s*(?:-|–|to)\s*/) : r && typeof r === "object" ? [String((r as any).start), String((r as any).end)] : [];
      if (parts.length !== 2) throw new Error(`hours.${key}: write each range as "09:00-17:00".`);
      const start = readTime(parts[0], `hours.${key}`);
      const end = readTime(parts[1], `hours.${key}`);
      if (end <= start) throw new Error(`hours.${key}: each range must end after it starts, on the same day.`);
      return [start, end];
    });
    for (const d of days) out[d] = tidy([...out[d], ...ranges]);
  }
  return out;
}

export function stringifyHours(h: Hours): string {
  return JSON.stringify(Object.fromEntries(WEEKDAYS.map((d) => [d, tidy(h[d] || []).map(([a, b]) => `${hhmm(a)}-${hhmm(b)}`)])));
}

/** The stored hours JSON. Bad data reads as closed, never as an error. */
export function parseHours(raw: string | null | undefined): Hours {
  try {
    return readHours(JSON.parse(raw || "{}"));
  } catch {
    return { sun: [], mon: [], tue: [], wed: [], thu: [], fri: [], sat: [] };
  }
}

export function hasHours(h: Hours): boolean {
  return WEEKDAYS.some((d) => h[d].length > 0);
}

/** "9:00 AM", for minutes from midnight. */
export function formatMinutes(min: number, locale?: string): string {
  return new Intl.DateTimeFormat(locale, { timeZone: "UTC", hour: "numeric", minute: "2-digit" }).format(new Date(Date.UTC(2026, 0, 1, 0, min)));
}

/** ["Mon to Fri: 9:00 AM to 5:00 PM", "Sat: 10:00 AM to 1:00 PM"], days in the order given. */
export function hoursSummary(h: Hours, order: Weekday[] = [...WEEKDAYS.slice(1), "sun"], locale?: string): string[] {
  const label = (d: Weekday) => h[d].map(([a, b]) => (a === 0 && b === 1440 ? "all day" : `${formatMinutes(a, locale)} to ${b === 1440 ? "midnight" : formatMinutes(b, locale)}`)).join(", ");
  const lines: string[] = [];
  let i = 0;
  while (i < order.length) {
    const text = label(order[i]);
    let j = i;
    while (j + 1 < order.length && label(order[j + 1]) === text) j++;
    const days = j - i === 6 ? "Every day" : `${WEEKDAY_SHORT[order[i]]}${j > i ? `${j > i + 1 ? " to " : ", "}${WEEKDAY_SHORT[order[j]]}` : ""}`;
    if (text) lines.push(`${days}: ${text}`);
    i = j + 1;
  }
  return lines;
}

/* ---------- Days off ---------- */

/** An inclusive range of dates ("2026-12-24", "2026-12-31") in the page's time zone when it takes no bookings. */
export type DayRange = [string, string];

const realDate = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && utcDayToDate(dateToUtcDay(s)) === s;

/** Sorts and merges ranges that touch or overlap. */
function tidyDays(ranges: DayRange[]): DayRange[] {
  const out: DayRange[] = [];
  for (const [a, b] of [...ranges].sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0))) {
    const last = out[out.length - 1];
    if (last && a <= addDays(last[1], 1)) { if (b > last[1]) last[1] = b; }
    else out.push([a, b]);
  }
  return out;
}

/**
 * Reads days off a person or an assistant wrote: ["2026-12-24", "2026-12-28 to 2026-12-31", {"from": "2027-01-04", "to": "2027-01-08"}].
 * Drops ranges that ended before `today`. Throws a sentence for anything it cannot read.
 */
export function readDaysOff(input: unknown, today: string): DayRange[] {
  if (input == null) return [];
  if (!Array.isArray(input)) throw new Error(`daysOff must be a list, like ["2026-12-24", "2026-12-28 to 2026-12-31"].`);
  const ranges = input.map((x): DayRange => {
    const parts = typeof x === "string" ? x.split(/\s+to\s+|\s*\/\s*|\s*\.\.\s*/) : Array.isArray(x) ? x.map(String) : x && typeof x === "object" ? [String((x as any).from ?? ""), String((x as any).to ?? (x as any).from ?? "")] : [];
    const [a = "", b = a] = parts.map((s) => s.trim());
    if (parts.length > 2 || !realDate(a) || !realDate(b)) throw new Error(`daysOff: ${JSON.stringify(x)} is not a date like "2026-12-24" or a range like "2026-12-28 to 2026-12-31".`);
    if (b < a) throw new Error(`daysOff: ${JSON.stringify(x)} ends before it starts.`);
    return [a, b];
  });
  const kept = tidyDays(ranges).filter(([, b]) => b >= today);
  if (kept.length > LIMITS.daysOff) throw new Error(`Keep daysOff to ${LIMITS.daysOff} ranges or fewer.`);
  return kept;
}

/** The stored days off. Bad data reads as none, never as an error. */
export function parseDaysOff(raw: string | null | undefined): DayRange[] {
  try {
    const list = JSON.parse(raw || "[]");
    if (!Array.isArray(list)) return [];
    return tidyDays(list.filter((r) => Array.isArray(r) && realDate(String(r[0])) && realDate(String(r[1])) && r[1] >= r[0]).map((r) => [r[0], r[1]] as DayRange));
  } catch {
    return [];
  }
}

export const isDayOff = (date: string, ranges: DayRange[]) => ranges.some(([a, b]) => date >= a && date <= b);

/** "Thu, Dec 24", or "Mon, Dec 28 to Thu, Dec 31", with the year when it is not this year. */
export function dayRangeLabel([a, b]: DayRange, locale?: string): string {
  const year = String(new Date().getUTCFullYear());
  const f = (d: string) => fmt(locale, { timeZone: "UTC", weekday: "short", month: "short", day: "numeric", ...(d.startsWith(year) ? {} : { year: "numeric" }) }).format(new Date(dateToUtcDay(d) * 1000));
  return a === b ? f(a) : `${f(a)} to ${f(b)}`;
}

/* ---------- 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);
  return { date: `${p.y}-${pad(p.mo)}-${pad(p.d)}`, time: `${pad(p.h)}:${pad(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 abs = Math.abs(off);
  return `${date}T${time}:00${off < 0 ? "-" : "+"}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
}

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);
}
export const addDays = (date: string, n: number) => utcDayToDate(dateToUtcDay(date) + n * 86400);
export const weekdayOf = (date: string): Weekday => WEEKDAYS[new Date(dateToUtcDay(date) * 1000).getUTCDay()];

/** "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 (EDT)" when the given instants share one abbreviation, else "New York time". */
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`;
}

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));
}

/** "Wednesday, October 21" (long) or "Wed, Oct 21" (short), with the year when it is not this year. */
export function formatDay(sec: number, tz: string, locale?: string, style: "short" | "long" = "long"): string {
  const thisYear = wallClock(sec, tz).date.startsWith(wallClock(Date.now() / 1000, tz).date.slice(0, 4));
  return fmt(locale, { timeZone: tz, weekday: style, month: style, day: "numeric", ...(thisYear ? {} : { year: "numeric" }) }).format(new Date(sec * 1000));
}

/** "Wed, Oct 21, 3:00 PM to 3:30 PM EDT". */
export function slotLabel(start: number, end: number, tz: string, locale = "en-US"): string {
  return `${formatDay(start, tz, locale, "short")}, ${formatTime(start, tz, locale)} to ${formatTime(end, tz, locale)} ${zoneAbbr(tz, start, locale)}`;
}

/* ---------- Open times ---------- */

/** Start times step by the meeting length, but at most every 30 minutes, so longer meetings can start on the half hour. */
export const stepMinutes = (durationMin: number) => Math.min(durationMin, 30);

/** The 15-minute cells a booking holds: from its start to the end of the buffer after it. */
export function cellsFor(start: number, heldUntil: number): number[] {
  const out: number[] = [];
  for (let t = start; t < heldUntil; t += CELL) out.push(t);
  return out;
}

type SlotRules = Pick<Page, "durationMin" | "bufferMin" | "timezone" | "hours" | "daysOff" | "noticeHours" | "horizonDays">;

/** The latest start the page takes right now, and the earliest. */
export function bookingWindow(page: SlotRules, now: number): { earliest: number; latest: number } {
  return { earliest: now + page.noticeHours * 3600, latest: now + page.horizonDays * 86400 };
}

/**
 * Every open start time in [from, to): inside the weekly hours and not on a day off, after the minimum
 * notice, within the booking window, and with every cell it would hold (meeting plus buffer) still free.
 */
export function openSlots(page: SlotRules, taken: Set<number>, now: number, from: number, to: number): number[] {
  const tz = page.timezone;
  const hours = parseHours(page.hours);
  const off = parseDaysOff(page.daysOff);
  const { earliest, latest } = bookingWindow(page, now);
  const lo = Math.max(from, earliest);
  const hi = Math.min(to, latest + 1);
  if (hi <= lo) return [];
  const step = stepMinutes(page.durationMin);
  const hold = (page.durationMin + page.bufferMin) * 60;
  const out: number[] = [];
  const last = wallClock(hi + 86400, tz).date;
  for (let date = wallClock(lo - 86400, tz).date; date <= last; date = addDays(date, 1)) {
    if (isDayOff(date, off)) continue;
    for (const [a, b] of hours[weekdayOf(date)]) {
      for (let t = a; t + page.durationMin <= b; t += step) {
        const time = hhmm(t);
        const start = zonedToUtc(date, time, tz);
        if (start < lo || start >= hi) continue;
        const wall = wallClock(start, tz);
        if (wall.date !== date || wall.time !== time) continue; // a time skipped by a clock change
        let free = true;
        for (let c = start; c < start + hold; c += CELL) if (taken.has(c)) { free = false; break; }
        if (free) out.push(start);
      }
    }
  }
  return [...new Set(out)].sort((x, y) => x - y);
}

/* ---------- Pages, people, links ---------- */

export const HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]{1,30})[a-z0-9]$/;
const RESERVED = new Set(["api", "app", "admin", "art", "assets", "book", "booking", "bookings", "edit", "fonts", "help", "home", "llms", "new", "openapps", "page", "pages", "privacy", "robots", "settings", "sitemap", "src", "terms", "www"]);

/** A handle, or a sentence saying what is wrong with it. */
export function handleProblem(handle: string): string | null {
  if (handle.length < 3) return "Use at least 3 characters.";
  if (handle.length > 32) return "Use at most 32 characters.";
  if (!HANDLE_RE.test(handle) || handle.includes("--")) return "Use lowercase letters, numbers and single dashes, starting and ending with a letter or number.";
  if (RESERVED.has(handle)) return "That address is reserved. Try another.";
  return null;
}

/** "Sam Lee" -> "sam-lee". */
export function suggestHandle(name: string): string {
  return name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "");
}

export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/** The meeting link as typed, cleaned, or throws a sentence. Only https links: a meeting room someone joins. */
export function checkMeetingUrl(raw: string): string {
  const s = raw.trim();
  let url: URL;
  try {
    url = new URL(s);
  } catch {
    throw new Error("The meeting link must be a full link, like https://meet.google.com/abc-defg-hij.");
  }
  if (url.protocol !== "https:") throw new Error("The meeting link must start with https://.");
  if (!url.hostname.includes(".")) throw new Error("The meeting link must be a full link, like https://meet.google.com/abc-defg-hij.");
  if (s.length > LIMITS.url) throw new Error(`Keep the meeting link under ${LIMITS.url} characters.`);
  return s;
}

/** "Google Meet", "Zoom", "Microsoft Teams" or "Video call", from the link. */
export function meetingKind(url: string): string {
  let host = "";
  try { host = new URL(url).hostname.toLowerCase(); } catch { /* not a link */ }
  if (host === "meet.google.com") return "Google Meet";
  if (host === "zoom.us" || host.endsWith(".zoom.us")) return "Zoom";
  if (host === "teams.microsoft.com" || host === "teams.live.com") return "Microsoft Teams";
  if (host.endsWith("whereby.com")) return "Whereby";
  return "Video call";
}

export function parseQuestions(raw: string | null | undefined): Question[] {
  try {
    const list = JSON.parse(raw || "[]");
    return Array.isArray(list) ? list.filter((q) => q && typeof q.label === "string" && q.label.trim()).map((q) => ({ label: q.label, required: Boolean(q.required) })) : [];
  } catch {
    return [];
  }
}

/** Questions from a person or an assistant: ["What should we talk about?"] or [{label, required}]. */
export function readQuestions(input: unknown): Question[] {
  if (input == null) return [];
  if (!Array.isArray(input)) throw new Error(`questions must be a list, like ["What would you like to talk about?"].`);
  if (input.length > LIMITS.questions) throw new Error(`Ask at most ${LIMITS.questions} questions.`);
  return input.map((q) => {
    const label = String(typeof q === "string" ? q : q?.label ?? "").trim();
    if (!label) throw new Error("Each question needs some text.");
    if (label.length > LIMITS.question) throw new Error(`Keep each question under ${LIMITS.question} characters.`);
    return { label, required: typeof q === "object" && q ? Boolean(q.required) : false };
  });
}

export function parseAnswers(raw: string | null | undefined): Answer[] {
  try {
    const list = JSON.parse(raw || "[]");
    return Array.isArray(list) ? list.filter((x) => x && typeof x.q === "string" && typeof x.a === "string") : [];
  } catch {
    return [];
  }
}

/* ---------- Calendar ---------- */

function icsStamp(sec: number): string {
  return new Date(sec * 1000).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
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 ");
}

export interface CalendarEvent { uid: string; title: string; start: number; end: number; location: string; details: string; cancelled?: boolean }

function vevent(e: CalendarEvent, now: number): string[] {
  return [
    "BEGIN:VEVENT",
    `UID:${e.uid}@book-free.openapps.xyz`,
    `DTSTAMP:${icsStamp(now)}`,
    `DTSTART:${icsStamp(e.start)}`,
    `DTEND:${icsStamp(e.end)}`,
    `SUMMARY:${icsText(e.title)}`,
    `DESCRIPTION:${icsText(e.details)}`,
    `LOCATION:${icsText(e.location)}`,
    ...(/^https:\/\//.test(e.location) ? [`URL:${e.location}`] : []),
    `STATUS:${e.cancelled ? "CANCELLED" : "CONFIRMED"}`,
    "END:VEVENT",
  ];
}

function calendar(head: string[], events: string[]): string {
  return ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//OpenApps//Book//EN", "CALSCALE:GREGORIAN", ...head, ...events, "END:VCALENDAR"].map(fold).join("\r\n") + "\r\n";
}

/** One meeting as a calendar file. */
export function icsFile(e: CalendarEvent, now = Date.now() / 1000): string {
  return calendar([e.cancelled ? "METHOD:CANCEL" : "METHOD:PUBLISH"], vevent(e, now));
}

/** A page's bookings as a calendar the owner subscribes to. Calendar apps re-read it on their own schedule. */
export function icsFeed(name: string, events: CalendarEvent[], now = Date.now() / 1000): string {
  return calendar(["METHOD:PUBLISH", `X-WR-CALNAME:${icsText(name)}`, "REFRESH-INTERVAL;VALUE=DURATION:PT1H", "X-PUBLISHED-TTL:PT1H"], events.flatMap((e) => vevent(e, now)));
}

/** The calendar feed's key: derived from the owner key, so it shows bookings but cannot change anything or be turned back into the owner key. */
export async function feedKey(ownerKey: string): Promise<string> {
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`book-feed:${ownerKey}`));
  return [...new Uint8Array(hash)].slice(0, 16).map((b) => b.toString(16).padStart(2, "0")).join("");
}

export function googleCalendarUrl(e: CalendarEvent): string {
  const q = new URLSearchParams({ action: "TEMPLATE", text: e.title, dates: `${icsStamp(e.start)}/${icsStamp(e.end)}`, details: e.details, location: e.location });
  return `https://calendar.google.com/calendar/render?${q}`;
}

/**
 * The meeting as a calendar event, for the guest (who meets the page's owner) or the owner (who meets the guest).
 * `link` is the guest's manage link, or the owner's dashboard.
 */
export function meetingEvent(
  bookingId: string, page: Pick<Page, "title" | "name">, booking: Pick<Booking, "start" | "end" | "name" | "status"> & Partial<Pick<Booking, "email" | "note" | "answers">>,
  meetingUrl: string, link: string, forOwner = false,
): CalendarEvent {
  const guest = forOwner ? [
    `Guest: ${booking.name}${booking.email ? `, ${booking.email}` : ""}`,
    ...parseAnswers(booking.answers).map((x) => `${x.q}\n${x.a}`),
    booking.note ? `Note: ${booking.note}` : null,
  ] : [];
  return {
    uid: bookingId,
    title: `${page.title} with ${forOwner ? booking.name : page.name}`,
    start: booking.start,
    end: booking.end,
    location: meetingUrl,
    details: [
      `Join: ${meetingUrl}`,
      ...guest,
      forOwner ? `Your bookings: ${link}` : `Change of plans? Move or cancel it here: ${link}`,
      "Booked with Book, a free booking page: https://book-free.openapps.xyz/",
    ].filter(Boolean).join("\n\n"),
    cancelled: booking.status === "cancelled",
  };
}

/** A filename-safe version of a title. */
export function fileName(title: string): string {
  return (title.trim().replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "") || "meeting").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

Add favicon.ico and link tag
f8f1b3a801 · 2 hours ago
Quality pass: guest reschedule, days off, calendar feed, API docs
c827e5d073 · 5 hours ago
Bounded source sync
68e67b4092 · 1 day ago
Bounded source sync
ab2e006f9b · 1 day ago
Bounded source sync
bb10824b54 · 1 day ago
Bounded source sync
d22f765fb7 · 1 day ago
Bounded source sync
30b4b06513 · 1 day ago
Bounded source sync
c3d297477a · 1 day ago
Bounded source sync
7b18317417 · 1 day ago