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 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.
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
Quality pass: guest reschedule, days off, calendar feed, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync