vibes.diy/docs Home Discord
creator docs · reference · generated

Dates and calendars — the app knows today; subscriptions as a live .ics feed

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/calendar.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

Dates — the app knows today

An app that keeps a date — a due date, a deadline, a run of daily check-ins, a service schedule, a reminder, a timeline — is working against a clock that is always on. new Date() is today, in the person's own time zone, at the moment they type. The build context states today's date too (the <today> line), so a record you write and a sample you author both start from a known day.

Relative words resolve against now, save at once, and show the resolved date. "Tomorrow", "Friday", "next week", "the 15th" each name exactly one day from where the person stands; resolve it with new Date() when the record is saved, save the ISO day, and show that day beside the record so one tap adjusts it. The saved card is the confirmation and the date on it is the question answered. A sentence-intake schema call reads dates the same way: the person's local day goes into its prompt (Today: ${keyOf(new Date())}, with keyOf below), as the intake samples in the fireproof and callai docs do with their own Today:. Days are kept as local YYYY-MM-DD strings (keyOf), so a date stays the day the person meant across time zones.

App.jsx

jsximport React, { useState } from "react";
import { useFireproof } from "use-fireproof";
import { Button, Field, Input, Row, Stack, Text } from "@vibes.diy/pouf";

const pad = (n) => String(n).padStart(2, "0");
const keyOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];

const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
// "the 15th" → the next month, this one included, that has a 15th and has not passed it.
function nextDayOfMonth(n, now) {
  if (n < 1 || n > 31) return null;
  for (let i = 0; i < 12; i += 1) {
    const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
    if (n > daysInMonth(d.getFullYear(), d.getMonth())) continue;
    d.setDate(n);
    if (d >= now || keyOf(d) === keyOf(now)) return d;
  }
  return null;
}

// "tomorrow", "friday", "next week", "the 15th" → the one day they mean from today.
function resolveDay(words, now = new Date()) {
  const text = words.toLowerCase();
  const day = new Date(now);
  const weekday = DAYS.findIndex((name) => text.includes(name));
  if (text.includes("tomorrow")) day.setDate(day.getDate() + 1);
  else if (text.includes("next week")) day.setDate(day.getDate() + 7);
  else if (weekday >= 0) day.setDate(day.getDate() + ((weekday - day.getDay() + 7) % 7 || 7));
  else {
    const nth = text.match(/\bthe (\d{1,2})(?:st|nd|rd|th)?\b/);
    if (nth) return nextDayOfMonth(Number(nth[1]), now) && keyOf(nextDayOfMonth(Number(nth[1]), now));
  }
  return keyOf(day);
}

export default function App() {
  const { database, useLiveQuery } = useFireproof("practice-log");
  const { docs: sessions } = useLiveQuery("type", { key: "session" });
  const [words, setWords] = useState("");
  const [day, setDay] = useState(keyOf(new Date()));
  const today = keyOf(new Date());
  const plan = (text) => {
    setWords(text);
    // A day the words name lands in the date field; "the 31st" in a short month leaves it for the person to pick.
    setDay(resolveDay(text) ?? "");
  };
  const save = async () => {
    if (!words.trim() || !day) return;
    setWords(""); // ready for the next session at once
    setDay(today);
    const { ok } = await database.put({ type: "session", piece: words.trim(), day, createdAt: Date.now() });
    if (!ok) setWords((now) => now || words);
  };
  return (
    <Stack gap={3}>
      <Field label="Plan a practice">{(id, d) => <Input id={id} describedBy={d} value={words} onChange={plan} placeholder="Scales on friday" />}</Field>
      <Row gap={2}>
        <Field label="On">{(id, d) => <Input id={id} describedBy={d} type="date" value={day} onChange={setDay} />}</Field>
        <Button onClick={save}>Save</Button>
      </Row>
      {sessions.map((s) => (
        <Row key={s._id} gap={2}>
          <Text>{s.piece}</Text>
          <Text size="sm" muted>{s.day === today ? "today" : s.day}</Text>
        </Row>
      ))}
    </Stack>
  );
}

Sample records are dated around the day they are written. A demo opens alive when its examples sit where the app's own views look: recent activity in the last few days, an unbroken run of daily entries reaching today, upcoming items this week and next, a month grid with this month's entries on it. Write each seed date from the <today> line as an offset — yesterday, three days ago, this coming Saturday — so a reader of the seed can see the run reaching the day it was written. The example below was written on a <today> of 2026-10-06: day runs three days ago, two days ago, yesterday, today, and the plan lands on the coming Saturday (2026-10-10); on another day, the same offsets give other dates.

seed.json

json{
  "practice-log": [
    { "key": "scales-1", "type": "session", "piece": "G major scales", "day": "2026-10-03" },
    { "key": "etude-1", "type": "session", "piece": "Kreutzer etude 2", "day": "2026-10-04" },
    { "key": "scales-2", "type": "session", "piece": "G major scales, faster", "day": "2026-10-05" },
    { "key": "sonata", "type": "session", "piece": "Sonata first movement", "day": "2026-10-06" },
    { "key": "recital", "type": "session", "piece": "Run the recital program", "day": "2026-10-10" }
  ]
}

Calendar subscriptions — a live .ics feed of each user's items

When an app has per-user dated items — favorited events, RSVPs, picks, bookings, shifts — offer a calendar subscription: a webcal:// link that puts the user's items on their phone calendar and keeps them updated as they add more. This is a backend.js feature (see the backend docs for the general handler contract); this page is the recipe that makes subscriptions actually work on real devices.

The subscription is a live feed the phone re-fetches: a webcal:// link backed by a backend.js that keeps serving each user's current items as they add more. So this feature is always three pieces working together — a backend.js (the scheduled tick + fetch handler in "Complete backend.js" below), a per-user token, and a webcal:// link in the UI. Emit all three and the calendar stays in sync on the user's device automatically. On a page people set up for their own visitors (a booking page), the creator's feed holds the bookings made on their page, which their clients wrote — see A page's bookings, for the provider who runs it at the end.

A calendar subscription is a live URL the phone re-fetches — backend.js serves it and the app links to it with webcal://, so the user's items keep arriving as they add more. Every subscription GET answers with a valid calendar, including the first one after a deploy and the first one after a token is minted.

The architecture (each piece is load-bearing)

Calendar clients (iPhone, Google) refresh subscriptions with anonymous GETs — no login, no headers you control. An anonymous fetch handler cannot read the app's databases (anonymous ctx.db.query is denied, and the fetch lane can't query access-fn-bound databases at all). So:

  1. A scheduled tick aggregates; the fetch lane serves. scheduled runs as the owner in admin mode and CAN read everything. Every minute it builds handle → items into a module-level variable. All handlers share one isolate, so the anonymous GET reads that cache. Use interval: "1m" — every deploy replaces the isolate (empty cache), and the window must stay short.
  2. The URL carries a per-user RANDOM TOKEN — a capability, not a handle. Subscription URLs are unauthenticated and long-lived; a handle-keyed URL (?u=<handle>) is guessable, which silently makes every user's items world-readable. Instead, MINT the token LOCALLY: the first time the user opens the calendar surface with subscribable content, generate a crypto.getRandomValues token client-side and put a caltoken doc { type: "caltoken", userId, token }, kept PRIVATE by the access function (owner-only channel, like notes — the token IS the secret; the scheduled lane reads it regardless). Local minting makes the URL available instantly (the optimistic write reaches the live query before the click finishes), and it stays opt-in: users who never open that surface get no token and no ics aggregate — the scheduled tick must skip aggregating any user without a token. The URL is ?t=<token>&n=<handle> (n is a validated DISPLAY label only — iOS captures the calendar name at subscribe time, often before the tick has learned a fresh token; the token alone gates data): unguessable, shareable on purpose (handing out the link is the sharing feature), and revocable — delete the token doc and the feed drains on the next refresh. Because the token maps to the user (not to item ids), the feed is still LIVE: new items reach every subscriber automatically. Never let private types (notes, drafts) enter the aggregation.
  3. Never answer a subscription GET with an error or emptiness you can avoid. iOS validates a new subscription by fetching the URL at add time and shows "Validation failed" on ANY failure — including 503. A cold cache (isolate just booted, tick hasn't run) must return a valid calendar (empty, or a hard-coded real anchor event like the festival's "Gates Open") with cache-control: no-store. Reserve error statuses (503 + Retry-After: 300) for an upstream-join failure where ANY item would have no fallback data — an empty or partial 200 there would wipe or shrink the subscriber's previously-synced events, while an error makes clients keep them and retry.
  4. Validate per item when serving, all-or-nothing never. One malformed legacy doc must drop out of the feed, not 400 it.
  5. The URL is the subscription's identity — freeze it at first ship. Subscribers hold the exact URL; changing the path or parameter shape later strands every existing subscription. Renames go in X-WR-CALNAME only — and even those reach only NEW subscribers, because clients capture the calendar name at subscribe time (same reason the n= label exists). Choose the path and display name like you can never change them.
  6. If items mirror an external events API, re-join it on each refresh so times stay current and cancellations drop off. Call it as globalThis.fetch(...) — inside backend.js, bare fetch is your own exported handler. Treat a 200 whose body isn't the expected shape as a FAILURE (some APIs return 200 {"error": …}), and fall back to stored item data rather than erroring when you have it.

ICS format rules (calendar clients are strict)

  • CRLF (\r\n) line endings; final line too.
  • Fold lines at 75 octets (bytes, not characters — never split a multibyte character); continuation lines start with one space that counts toward their own 75.
  • Escape TEXT values (SUMMARY/LOCATION/DESCRIPTION/X-WR-CALNAME): backslash first, then ;, ,, and newlines → \n.
  • URL is URI-valued, not TEXT — emit it verbatim (escaping commas corrupts links), which means you must reject URLs containing whitespace or control characters (an embedded CRLF would inject calendar lines).
  • DTEND must be strictly after DTSTART. Reject zero-duration items; treat same-day end-before-start as an overnight item and add 24h; default a missing end to start + 2h.
  • All times in UTC (20260731T200000Z), converted from the app's local timezone with an Intl.DateTimeFormat offset probe (handles DST — never hardcode an offset).
  • Stable UIDs keyed by item identity (item-<id>@<app>.vibes.diy) so a refresh updates events instead of duplicating them.
  • Include X-WR-CALNAME:@<handle> — <App Name> and refresh hints (REFRESH-INTERVAL;VALUE=DURATION:PT6H + X-PUBLISHED-TTL:PT6H).

The UI

The token is auto-minted when the calendar surface opens, so the Subscribe button is simply there once the user has subscribable content.

⚠️ Read the token from its OWN live query on type: "caltoken" (as below). A caltoken doc never appears in your items/favorites query — looking for it there means the Subscribe link never renders, even though the mint effect and the feed itself work.

App.jsx

jsx// access.js needs a `caltoken` branch routing the doc to the owner's PRIVATE
// channel (like notes) — the token is the secret.
const { docs: calTokens } = useLiveQuery(byTypeUser, { key: ["caltoken", me.userHandle] });
const calToken = calTokens[0]?.token || null;
useEffect(() => {
  // Local minting on first visit to this view with content: the URL is ready
  // instantly (optimistic write), and users who never come here never get a
  // token. Until the backend tick learns the token (≤1m), the endpoint serves
  // a valid placeholder calendar, so even an immediate subscribe can't fail.
  if (!signedIn || myItems.length === 0 || calToken) return;
  const b = new Uint8Array(16);
  crypto.getRandomValues(b);
  let bin = "";
  for (const x of b) bin += String.fromCharCode(x);
  const token = btoa(bin).replace(/[+]/g, "-").replace(/[/]/g, "_").replace(/=+$/, "");
  database.put({ _id: `caltoken-${me.userHandle}`, type: "caltoken", userId: me.userHandle, token, createdAt: Date.now() }).catch(() => {});
}, [signedIn, myItems.length, calToken]);
// The feed lives at the app's public address, which also serves backend.js:
// https://vibes.diy/vibe/<handle>/<app>/_api/faves.ics. The app's own host,
// `<app>--<handle>.…`, names both in its first label.
const label = location.hostname.split(".")[0];
const cut = label.indexOf("--");
const appAddress = `vibes.diy/vibe/${label.slice(cut + 2)}/${label.slice(0, cut)}`;
const icsUrl = signedIn && calToken
  ? `${appAddress}/_api/faves.ics?t=${encodeURIComponent(calToken)}&n=${encodeURIComponent(me.userHandle)}`
  : null;
{icsUrl && (
  <a href={`webcal://${icsUrl}`} target="_blank" rel="noopener noreferrer"
     title="Subscribe in your phone's calendar — it follows your picks live. iOS may warn about an insecure connection; tap Continue. Share the link and friends can follow your picks.">
    📆 Subscribe on iPhone
  </a>
)}
// Plus a copy button that copies `https://${icsUrl}` —
// that form pastes into Google Calendar (From URL) and into iOS Settings →
// Calendar → Add Subscribed Calendar (which skips the warning entirely).

Use webcal://, never webcals:// — iOS Safari rejects the secure-scheme variant as an invalid address. The one-time "Insecure Connection" prompt is expected; Continue works (fetches ride the https redirect). Tell the user in the button tooltip.

Complete backend.js

favorite docs look like { type: "favorite", userId, itemId, event: { date: "2026-07-31", time: "18:00:00", endtime: "", title, venue, url } } — adapt the doc shape, db name, and timezone to the app.

backend.js

jsexport const config = { scheduled: { interval: "1m" } };

const TZ = "America/Los_Angeles"; // the app's local timezone
const fmt = 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" });
const tzOffsetMin = (d) => {
  const p = Object.fromEntries(fmt.formatToParts(d).map((x) => [x.type, x.value]));
  return (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - d.getTime()) / 60000;
};
const parseLocal = (s) => {
  if (typeof s !== "string" || !s) return null;
  if (/([+-]\d\d:\d\d|Z)$/.test(s)) { const d = new Date(s); return isNaN(d) ? null : d; }
  const guess = new Date(s.replace(" ", "T") + "Z");
  if (isNaN(guess)) return null;
  return new Date(guess.getTime() - tzOffsetMin(guess) * 60000);
};
const utc = (ms) => new Date(ms).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
const octets = (ch) => { const c = ch.codePointAt(0); return c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; };
const fold = (line) => {
  const parts = []; let cur = "", bytes = 0, budget = 75;
  for (const ch of line) { const n = octets(ch);
    if (bytes + n > budget) { parts.push(cur); cur = ""; bytes = 0; budget = 74; }
    cur += ch; bytes += n; }
  parts.push(cur); return parts.join("\r\n ");
};

// event snapshot → validated item, or null (bad rows drop out per item).
const toItem = (itemId, ev) => {
  if (!ev || !ev.date || !ev.time || !ev.title) return null;
  const start = parseLocal(`${ev.date}T${ev.time}`);
  if (!start) return null;
  let end = ev.endtime && ev.endtime !== ev.time ? parseLocal(`${ev.date}T${ev.endtime}`) : null;
  let endMs = end ? end.getTime() : start.getTime() + 2 * 3600_000; // default 2h
  if (endMs < start.getTime()) endMs += 24 * 3600_000; // overnight (end before start, same-day form)
  if (endMs <= start.getTime()) return null;
  const url = typeof ev.url === "string" && /^https?:\/\/[^\s\x00-\x1f\x7f]+$/i.test(ev.url) ? ev.url : null;
  return { id: itemId, title: ev.title, start: utc(start.getTime()), end: utc(endMs), venue: ev.venue, url };
};

const buildIcs = (handle, items) => {
  const stamp = utc(Date.now());
  const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//vibes.diy//app//EN", "CALSCALE:GREGORIAN",
    "METHOD:PUBLISH", `X-WR-CALNAME:${esc(`@${handle} — My Picks`)}`, `X-WR-TIMEZONE:${TZ}`,
    "REFRESH-INTERVAL;VALUE=DURATION:PT6H", "X-PUBLISHED-TTL:PT6H"];
  for (const it of [...items].sort((a, b) => (a.start < b.start ? -1 : 1))) {
    lines.push("BEGIN:VEVENT", `UID:item-${it.id.replace(/[^A-Za-z0-9._-]/g, "-")}@app.vibes.diy`,
      `DTSTAMP:${stamp}`, `DTSTART:${it.start}`, `DTEND:${it.end}`, `SUMMARY:${esc(it.title)}`);
    if (it.venue) lines.push(`LOCATION:${esc(it.venue)}`);
    if (it.url) lines.push(`URL:${it.url}`); // URI-valued: verbatim, pre-validated
    lines.push("END:VEVENT");
  }
  lines.push("END:VCALENDAR");
  return lines.map(fold).join("\r\n") + "\r\n";
};

let cache = null; // { users: handle → [{itemId, event}], tokens: token → handle }

export async function scheduled(event, ctx) {
  const docs = await ctx.db.query({ db: "items" }); // admin-mode read
  const users = new Map();
  const tokens = new Map();
  for (const d of docs) {
    if (d.type === "caltoken" && typeof d.token === "string" && /^[A-Za-z0-9_-]{16,64}$/.test(d.token) && d.userId) {
      tokens.set(d.token, String(d.userId).toLowerCase()); // the opt-in capability
      continue;
    }
    if (d.type !== "favorite" || !d.userId || d.itemId == null) continue; // ONLY shared types
    const k = String(d.userId).toLowerCase();
    if (!users.has(k)) users.set(k, []);
    users.get(k).push({ itemId: String(d.itemId), event: d.event });
  }
  // Opt-in means opt-in: drop aggregates for users without a token.
  const optedIn = new Set(tokens.values());
  for (const h of [...users.keys()]) if (!optedIn.has(h)) users.delete(h);
  cache = { users, tokens };
}

export async function fetch(request, ctx) {
  const url = new URL(request.url);
  if (url.pathname !== "/faves.ics") return new Response("not found", { status: 404 });
  if (request.method !== "GET" && request.method !== "HEAD")
    return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
  const t = url.searchParams.get("t") ?? "";
  if (!/^[A-Za-z0-9_-]{16,64}$/.test(t)) return new Response("pass t=<calendar token>", { status: 400 });
  // Display label only (token gates the data): iOS captures the calendar name
  // at subscribe time, often before the tick knows a freshly-minted token.
  const nRaw = (url.searchParams.get("n") ?? "").toLowerCase();
  const displayName = /^[a-z0-9][a-z0-9_-]{0,39}$/.test(nRaw) ? nRaw : null;
  const cold = cache === null; // tick hasn't run yet: serve VALID + empty, never 503
  // Unknown token → EMPTY valid calendar too: a just-created token can beat
  // the next tick (a 404 there fails iOS's add-time validation), and a
  // revoked token draining to empty is exactly what revocation should do.
  const handle = cold ? undefined : cache.tokens.get(t);
  const faves = (handle && cache.users.get(handle)) || [];
  const items = faves.map((f) => toItem(f.itemId, f.event)).filter(Boolean);
  return new Response(buildIcs(handle ?? displayName ?? "my", items), {
    status: 200,
    headers: { "content-type": "text/calendar; charset=utf-8",
      "cache-control": cold || !handle ? "no-store" : "public, max-age=300" },
  });
}

If items should track a live external events API, add the join inside fetch: fetch the API window with globalThis.fetch, key rows by the same item id, prefer the live row over the stored snapshot, skip items the API marks cancelled, and keep the snapshot path as the fallback when the API is down or the row is past the window.

A page's bookings, for the provider who runs it

On a page people set up for their own visitors — a booking page, office hours, a class sign-up — the page is a product its provider owns, and any signed-in person can run pages of their own (the access skill's A page a person sets up for their own visitors). So the feed the provider subscribes to is the bookings made on the pages they run, not the items they wrote: a client writes every booking, and the provider writes none. Keep the same three pieces and the same per-user token; change only what the tick files under each token. A booking names its page in pageId and a page names its provider in creatorHandle, so the tick reads the pages first, then files each booking under its page's provider and, separately, under its client — the client's own feed is their own bookings, the provider's is everyone's on their pages, and neither ever sees another client's:

backend.js

jsexport async function scheduled(event, ctx) {
  const docs = await ctx.db.query({ db: "bookings" }); // admin-mode read
  const users = new Map(); // handle → [{ itemId, event }], the shape `fetch` above serves
  const tokens = new Map();
  const pages = new Map(); // pageId → the page, whose creatorHandle is the provider who runs it
  // A booking's `start` is an ISO instant, which `toItem` reads as-is; it lasts its `minutes` (30 by default).
  const file = (handle, b, title) => {
    const k = String(handle).toLowerCase();
    if (!users.has(k)) users.set(k, []);
    const start = new Date(b.start);
    if (isNaN(start)) return;
    const end = new Date(start.getTime() + (b.minutes || 30) * 60000).toISOString();
    users.get(k).push({ itemId: String(b._id), event: { date: b.start.slice(0, 10), time: b.start.slice(11), endtime: end.slice(11), title } });
  };
  for (const d of docs) {
    if (d.type === "page" && d.creatorHandle) pages.set(d._id, d);
    if (d.type === "caltoken" && typeof d.token === "string" && /^[A-Za-z0-9_-]{16,64}$/.test(d.token) && d.userId) {
      tokens.set(d.token, String(d.userId).toLowerCase());
    }
  }
  for (const d of docs) {
    if (d.type !== "booking" || typeof d.start !== "string" || !pages.has(d.pageId)) continue;
    const page = pages.get(d.pageId);
    const provider = page.creatorHandle;
    file(provider, d, `${d.clientName || d.clientHandle}`); // the provider: every booking on their pages
    // The client: their own bookings, once — a provider booking their own page is filed already.
    if (d.clientHandle && String(d.clientHandle).toLowerCase() !== String(provider).toLowerCase()) file(d.clientHandle, d, page.name || "Appointment");
  }
  const optedIn = new Set(tokens.values());
  for (const h of [...users.keys()]) if (!optedIn.has(h)) users.delete(h);
  cache = { users, tokens };
}

A person who both runs a page and books on someone else's gets both in one feed. Name the calendar for the page (X-WR-CALNAME from the page's name) when the subscriber is its provider. The reminder the day before rides the same tick: walk the bookings, ctx.notify each client whose time is within a day, and mark the booking sent, a write the booking's rule admits from the scheduled run.