santa-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 b8b257a837b1
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.
← All files
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
b8b257a837 · 18 hours ago
Bind agent address in policy constants
9a72d0200b · 20 hours ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
60038fa63d · 20 hours ago
Bounded source sync
a2e0247232 · 1 day ago
Bounded source sync
c84c736f8b · 1 day ago
Bounded source sync
3a54a80cdc · 1 day ago
Bounded source sync
2eed41b4de · 1 day ago