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 c7891873549c9ca1004894b3
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/santa.ts
// Santa's shared logic for the web app and the public API: the draw, checks on input, the messages
// people copy and send, dates and the calendar file.
//
// The draw: a person never draws themselves or anyone they are kept apart from. Santa shuffles
// everyone at random and keeps the first shuffle that follows every rule, so every valid draw is
// equally likely. When the rules leave very few possible draws and shuffling finds none, it searches
// for one in a random order instead; when no draw can exist it says which people are stuck.
export type Status = "open" | "drawn";
export interface Exchange {
name: string;
budget: string;
date: string;
notes: string;
allowJoin: boolean;
organizerSees: boolean;
revealAt: number;
status: Status;
round: number;
drawnAt: number;
createdBy: string;
createdAt: number;
}
export interface Person { id: string; name: string; addedBy: string; self: boolean; createdAt: number }
export interface Open { id: string; pid: string; by: string; via: string; at: number }
export interface Match { id: string; to: string; round: number; at: number }
export interface Ask { id: string; text: string; answer: string; round: number; askedAt: number; answeredAt: number }
export interface Wish { text: string; at: number }
export type Pair = [string, string];
// people: a draw writes the exchange and every match in one batch, and a batch holds at most 100 documents.
export const LIMITS = { name: 80, budget: 40, notes: 1000, person: 60, contact: 120, wish: 2000, question: 300, answer: 500, people: 99 };
/** A sentence for a person about input that does not work. */
export class Problem extends Error {}
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. Keys are 24. */
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;
}
/* ---------- The draw ---------- */
/** Whole numbers below n with no bias, from the platform's secure random source. */
function makeRandom() {
const buf = new Uint32Array(256);
let i = buf.length;
const next = () => {
if (i === buf.length) { crypto.getRandomValues(buf); i = 0; }
return buf[i++];
};
return (n: number) => {
const limit = Math.floor(0x100000000 / n) * n;
for (;;) {
const x = next();
if (x < limit) return x % n;
}
};
}
function shuffle<T>(list: T[], below: (n: number) => number): T[] {
for (let i = list.length - 1; i > 0; i--) {
const j = below(i + 1);
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}
/** "Ann", "Ann and Ben", "Ann, Ben and Cat", "Ann, Ben, Cat and 4 others". */
export function nameList(names: string[]): string {
if (names.length > 4) return `${names.slice(0, 3).join(", ")} and ${names.length - 3} others`;
return names.length <= 1 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
}
export type DrawResult = { ok: true; matches: Record<string, string> } | { ok: false; problem: string };
/** Who gives to whom: giver id to receiver id. Pairs are kept apart both ways. */
export function drawNames(people: { id: string; name: string }[], pairs: Pair[]): DrawResult {
const n = people.length;
if (n < 3) return { ok: false, problem: "Add at least 3 people to draw names." };
if (n > LIMITS.people) return { ok: false, problem: `Santa draws up to ${LIMITS.people} people at a time. Split the group into two exchanges.` };
const index = new Map(people.map((p, i) => [p.id, i]));
const allowed = people.map((_, i) => people.map((__, j) => i !== j));
for (const [a, b] of pairs) {
const i = index.get(a);
const j = index.get(b);
if (i === undefined || j === undefined) continue;
allowed[i][j] = false;
allowed[j][i] = false;
}
for (let i = 0; i < n; i++) {
if (!allowed[i].some(Boolean)) return { ok: false, problem: `${people[i].name} is kept apart from everyone else, so there is nobody they can give to. Remove one of their pairs.` };
}
const below = makeRandom();
const order = people.map((_, i) => i);
const toResult = (receiverOf: number[]): DrawResult => ({ ok: true, matches: Object.fromEntries(people.map((p, i) => [p.id, people[receiverOf[i]].id])) });
// Uniform: shuffle and keep the first shuffle where nobody breaks a rule.
for (let attempt = 0; attempt < 50000; attempt++) {
shuffle(order, below);
let ok = true;
for (let i = 0; i < n && ok; i++) ok = allowed[i][order[i]];
if (ok) return toResult(order);
}
// Few valid draws left: a random-order search for one, or the people who make it impossible.
const receiverGiver = new Array<number>(n).fill(-1);
const options = allowed.map((row) => shuffle(row.flatMap((ok, j) => (ok ? [j] : [])), below));
for (const giver of shuffle(people.map((_, i) => i), below)) {
const seenGivers = new Set<number>();
const seenReceivers = new Set<number>();
const place = (u: number): boolean => {
seenGivers.add(u);
for (const v of options[u]) {
if (seenReceivers.has(v)) continue;
seenReceivers.add(v);
if (receiverGiver[v] < 0 || place(receiverGiver[v])) {
receiverGiver[v] = u;
return true;
}
}
return false;
};
if (!place(giver)) {
const stuck = [...seenGivers].map((i) => people[i].name);
const room = [...seenReceivers].map((i) => people[i].name);
return { ok: false, problem: `No draw can follow every rule: ${nameList(stuck)} can only give to ${nameList(room)}, and that is not enough people for all of them. Remove a pair you keep apart, or add someone.` };
}
}
const receiverOf = new Array<number>(n);
receiverGiver.forEach((g, r) => { receiverOf[g] = r; });
return toResult(receiverOf);
}
/* ---------- Checking input ---------- */
export function cleanText(value: unknown, max: number, label: string, required = false): string {
const text = value == null ? "" : String(value).trim();
if (required && !text) throw new Problem(`${label} is required.`);
if (text.length > max) throw new Problem(`Keep ${label} under ${max} characters.`);
return text;
}
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export function validDate(s: unknown): s is string {
if (typeof s !== "string" || !DATE_RE.test(s)) return false;
const d = new Date(`${s}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
}
export interface PersonInput { name: string; contact: string }
/** People from a person or an assistant: ["Ann", {"name": "Ben", "contact": "ben@example.com"}]. */
export function readPeople(input: unknown, taken: string[] = []): PersonInput[] {
if (input == null) return [];
if (!Array.isArray(input)) throw new Problem(`people must be a list, like ["Ann", "Ben", {"name": "Cat", "contact": "cat@example.com"}].`);
const seen = new Set(taken.map((n) => n.toLowerCase()));
return input.map((p) => {
const o = typeof p === "string" ? { name: p } : (p as Record<string, unknown> | null);
if (!o || typeof o !== "object") throw new Problem(`Each person is a name, or an object like {"name": "Cat", "contact": "cat@example.com"}.`);
const name = cleanText(o.name, LIMITS.person, "each name", true);
const contact = cleanText(o.contact ?? o.email ?? o.phone, LIMITS.contact, "each contact");
if (seen.has(name.toLowerCase())) throw new Problem(`Two people are called ${name}. Add a last initial so everyone can tell them apart.`);
seen.add(name.toLowerCase());
return { name, contact };
});
}
/** Pairs stored as JSON, kept to people who exist, each pair once. */
export function parsePairs(raw: string | null | undefined, ids?: Set<string>): Pair[] {
let list: unknown;
try { list = JSON.parse(raw || "[]"); } catch { return []; }
if (!Array.isArray(list)) return [];
const out: Pair[] = [];
const seen = new Set<string>();
for (const p of list) {
if (!Array.isArray(p) || p.length !== 2) continue;
const [a, b] = p.map(String);
if (a === b || (ids && (!ids.has(a) || !ids.has(b)))) continue;
const k = [a, b].sort().join("|");
if (seen.has(k)) continue;
seen.add(k);
out.push([a, b]);
}
return out;
}
export function contactKind(contact: string): "email" | "phone" | null {
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact)) return "email";
if (/^\+?[\d\s().-]{7,}$/.test(contact)) return "phone";
return null;
}
/* ---------- Dates ---------- */
/** "Friday, December 18" (with the year when it is not this year). */
export function formatDate(date: string, locale?: string): string {
const thisYear = date.startsWith(String(new Date().getUTCFullYear()));
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", weekday: "long", month: "long", day: "numeric", ...(thisYear ? {} : { year: "numeric" }) }).format(new Date(`${date}T12:00:00Z`));
}
/** "Friday, December 18 at 7:00 PM" in the reader's time zone (or the one given). */
export function formatMoment(sec: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, { timeZone, weekday: "long", month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: timeZone ? "short" : undefined }).format(new Date(sec * 1000));
}
/* ---------- Messages to copy and send ---------- */
type ExchangeFacts = Pick<Exchange, "name" | "budget" | "date">;
export function factsLine(ex: ExchangeFacts, locale?: string): string {
return [ex.budget && `Budget: ${ex.budget}.`, ex.date && `Gifts on ${formatDate(ex.date, locale)}.`].filter(Boolean).join(" ");
}
/** For the group chat: the join link, so people add themselves and write a wish list. */
export function inviteMessage(ex: ExchangeFacts, joinUrl: string, locale?: string): string {
return [`You're invited to our Secret Santa, ${ex.name}. Add your name and a wish list here: ${joinUrl}`, factsLine(ex, locale)].filter(Boolean).join("\n");
}
/** For one person: their private link. */
export function personMessage(ex: ExchangeFacts & Pick<Exchange, "status">, name: string, link: string, locale?: string): string {
const first = ex.status === "drawn"
? `Hi ${name}! Names are drawn for ${ex.name}. Your private link shows who you are getting a gift for, so keep it to yourself: ${link}`
: `Hi ${name}! Here is your private link for our Secret Santa, ${ex.name}. Write your wish list there now. Once names are drawn, it shows who you are getting a gift for, so keep it to yourself: ${link}`;
return [first, factsLine(ex, locale)].filter(Boolean).join("\n");
}
/** For the group chat after the draw: people who joined by link open it again on the same device. */
export function drawnMessage(ex: ExchangeFacts, joinUrl: string, locale?: string): string {
return [`Names are drawn for ${ex.name}! Open ${joinUrl} on the phone or computer you joined with to see who you are getting a gift for.`, factsLine(ex, locale)].filter(Boolean).join("\n");
}
export function privacyLine(ex: Pick<Exchange, "organizerSees">): string {
return ex.organizerSees
? "The organizer chose to see the full list of who gives to whom. Everyone in the exchange is told."
: "Nobody can see the full list of who gives to whom, not even the organizer. Each person's private link shows only their own match.";
}
/* ---------- Calendar ---------- */
const ics = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
const day = (date: string) => date.replace(/-/g, "");
const stamp = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
/** 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 ");
}
/** An all-day event on the exchange date, with a reminder a week before at 9 AM to get the gift. */
export function exchangeIcs(id: string, ex: ExchangeFacts & Pick<Exchange, "notes">, url: string): string {
const next = new Date(`${ex.date}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + 1);
const details = [factsLine(ex), ex.notes, url].filter(Boolean).join("\n");
const lines = [
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//OpenApps//Santa//EN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${id}@santa-free.openapps.xyz`,
`DTSTAMP:${stamp(Date.now())}`,
`DTSTART;VALUE=DATE:${day(ex.date)}`,
`DTEND;VALUE=DATE:${day(next.toISOString().slice(0, 10))}`,
`SUMMARY:${ics(`Secret Santa: ${ex.name}`)}`,
`DESCRIPTION:${ics(details)}`,
"BEGIN:VALARM", "ACTION:DISPLAY", `DESCRIPTION:${ics(`Get your gift for ${ex.name}`)}`, "TRIGGER:-P6DT15H", "END:VALARM",
"END:VEVENT", "END:VCALENDAR",
];
return lines.map(fold).join("\r\n") + "\r\n";
}
/** A filename-safe version of a title. */
export function fileName(title: string): string {
return (title.trim().replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "") || "secret-santa").slice(0, 60);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync