Dates and calendars — the app knows today; subscriptions as a live .ics feed
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:
- A
scheduledtick aggregates; thefetchlane serves.scheduledruns as the owner in admin mode and CAN read everything. Every minute it buildshandle → itemsinto a module-level variable. All handlers share one isolate, so the anonymous GET reads that cache. Useinterval: "1m"— every deploy replaces the isolate (empty cache), and the window must stay short. - 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 acrypto.getRandomValuestoken client-side and put acaltokendoc{ 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>(nis 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. - 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. - Validate per item when serving, all-or-nothing never. One malformed legacy doc must drop out of the feed, not 400 it.
- 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-CALNAMEonly — and even those reach only NEW subscribers, because clients capture the calendar name at subscribe time (same reason then=label exists). Choose the path and display name like you can never change them. - 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(...)— insidebackend.js, barefetchis your own exported handler. Treat a 200 whose body isn't the expected shape as a FAILURE (some APIs return200 {"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 anIntl.DateTimeFormatoffset 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.