Calendar Sync — Requirements #
A playground tool that turns any webpage of events into a Google-Calendar-subscribable URL.
You paste a source URL. The tool returns a stable .ics feed URL. You add it to Google Calendar. Whenever Google polls the feed, the tool scrapes the source live, extracts events (using Gemini for unstructured pages), and returns a fresh iCalendar.
No accounts. No dashboard. No state store. The subscribe URL is a deterministic hash of the source URL — same input, same URL, every time.
Problem statement #
Useful event data lives on webpages that don't expose a calendar feed. Copying dates into Google Calendar by hand is slow, error-prone, and doesn't stay in sync when the source page updates.
I want to point this tool at a URL and get back a subscribe URL that stays fresh, with as little infrastructure as possible.
Users #
Single-user prototype: me. If it works, share with friends. Not designing for multi-tenant, teams, or auth.
Design principle: stateless-first #
The default architecture is fully stateless:
- Feed URL = deterministic hash of the normalised source URL.
- Every request from Google Calendar triggers a live scrape + extraction.
- No database, no auth, no dashboard, no "my feeds" list.
Why this works: Google Calendar refreshes external calendars every 12–24 hours, so a personal-use feed gets scraped ~1–2 times per day. Gemini free tier absorbs that comfortably.
What we lose: no preview-and-confirm flow, no manual event edits, no "delete a noisy event" button. Extractions are trusted; the user spot-checks by opening the feed URL in a browser.
When to add state (v1.1): if Gemini costs get uncomfortable, if source pages are flaky and I want a last-good cache, or if I want a real preview flow. Netlify Blobs is the fallback.
Goals #
- Reduce time spent copying event data into Google Calendar by hand.
- Keep subscribed calendars fresh with zero manual re-subscription.
- Handle both structured feeds (
.ics, JSON-LD) and unstructured HTML.
- Keep infrastructure minimal — one serverless function, no store.
Non-goals #
- Multi-user accounts or shared calendars.
- Editing individual events after extraction.
- Two-way sync back to the source.
- Full CalDAV support.
- A dashboard of "my feeds" — the URL is the feed.
User needs #
As a user, I need to:
- Paste a source URL and immediately get back a subscribe URL.
- Recover a subscribe URL by re-pasting the same source URL (no login, no lookup).
- Spot-check the extraction by opening the feed URL in a browser.
- Filter out noise from a source without rebuilding the feed.
- Stop a subscription cleanly — by removing it from Google Calendar, nothing else.
Functional requirements #
1. URL → subscribe URL #
- Landing page: input box, "Get subscribe URL" button.
- Normalise the source URL (lowercase host, strip tracking params, resolve
webcal:// → https://).
- Hash the normalised URL (SHA-256) to produce a stable slug.
- Return two things:
- The subscribe URL:
https://<host>/feeds/<slug>.ics?src=<encoded-source>
- A one-tap "Add to Google Calendar" button that wraps that URL.
- Also show the URL in plain text so it can be copied.
The src query param is the source of truth for what to scrape. The slug is derived from it — so if the source URL changes, the subscribe URL changes too (correctly).
2. Live scrape on request #
When Google Calendar (or a browser) fetches the feed URL:
- Fetch the
src page.
- Detect source type and extract:
- Existing
.ics / webcal:// feed → parse directly, pass through.
- Page with JSON-LD
schema.org/Event → parse structured data.
- Page with microdata / microformats → parse structured data.
- Plain HTML with no structured data → Gemini extraction.
- Return a valid
.ics response with appropriate cache headers.
3. Event fields #
Each extracted event must have, at minimum:
- Title
- Start date/time (with timezone; assume the page's stated timezone, fall back to Europe/London)
- End date/time (or duration; default to 1h if missing)
- Location (optional)
- Description (optional — free text)
- URL back to the source page
Rules:
- Deduplicate by
(title + start) hash within a single scrape.
- Deterministic UID — derive from
sha256(sourceUrl + title + startISO) so the same event across scrapes produces the same UID, and Google Calendar updates rather than duplicates.
4. Filter noise via query params #
Instead of a database of user-specified excludes, keep filtering in the URL:
?exclude=<pattern> — comma-separated substrings to reject in event titles.
?since=<YYYY-MM-DD> — drop events before this date.
?until=<YYYY-MM-DD> — drop events after this date.
Changing filters means editing the subscribe URL in Google Calendar. Fine trade-off for zero state.
5. iCalendar output #
- Valid
.ics with VCALENDAR / VEVENT structure.
- Set
X-WR-CALNAME from the source page's <title>.
- Set
X-WR-TIMEZONE to the detected zone (or Europe/London fallback).
- Include per-event
UID, DTSTAMP, DTSTART, DTEND, SUMMARY, DESCRIPTION, LOCATION, URL.
Content-Type: text/calendar; charset=utf-8
Cache-Control: public, max-age=21600 (6h — Google will still poll on its own schedule, but this reduces cost if anything else hits the URL).
- Emit
ETag from a hash of the returned body so conditional requests get 304 Not Modified.
7. Failure behaviour #
- If the source fetch fails (404, timeout, blocked): return a valid but empty calendar with a
X-Calendar-Sync-Error header describing the failure, plus a single VEVENT visible to the user titled "⚠ Calendar Sync: source unreachable" so they notice in Google Calendar without their entire calendar going blank.
- Never 500 to Google Calendar's fetcher — a 500 will make Google mark the subscription as broken.
Non-functional requirements #
- Hosting: static frontend + one serverless function on Netlify (matches the rest of the site).
- AI: Gemini via my Google account for HTML extraction fallback.
- Auth: none. Feed URLs are unguessable-enough via the source-URL hash, but treat them as public — don't feed private/authenticated pages through this tool.
- Privacy: don't log full page contents beyond what's needed to debug a bad extraction; strip PII from Gemini prompts where possible.
- Cost: stay within Netlify + Gemini free tiers.
- Accessibility: the paste-URL landing page meets WCAG 2.2 AA.
- Ethics / etiquette: respect
robots.txt, send a descriptive User-Agent (CalendarSync/1.0 (+contact URL)), rate-limit re-fetches of the same source to at most 1 per minute to protect against thundering herds.
Key user flow — happy path #
- I open the app and paste a source URL.
- The app shows me the subscribe URL and an "Add to Google Calendar" button.
- I click the button, Google Calendar subscribes.
- Within 24h, the events appear in my calendar.
- Source page adds a new event; on Google's next poll (≤24h), it shows up.
- Later I notice an event I don't want. I edit the subscribe URL in Google Calendar to add
?exclude=xyz. Fixed.
Out of scope (v1) #
- Any persistent storage (no DB, no KV, no blobs).
- User accounts, dashboards, "my feeds" lists.
- Preview-and-confirm before subscribing.
- Manual per-event edits.
- Notifications (email/push).
- Bulk-import.
- Apple Calendar / Outlook-specific tweaks — should work as a side-effect of valid
.ics, but not testing against them.
- Recurring event heuristics beyond what's already structured on the source.
Assumptions #
- Source pages are publicly reachable (no login walls).
- I'm OK with a 12–24h lag between source change and Google Calendar showing it.
- Gemini extraction is good enough that occasional wrong events are tolerable — I can filter with
?exclude= or unsubscribe.
- One Gemini call per feed per day fits comfortably within the free tier.
- Netlify serverless functions can complete a fetch + Gemini call inside the timeout budget (10s hobby / 26s pro).
Open questions #
- URL normalisation aggressiveness: how far to go stripping tracking params? Too aggressive risks changing the slug for genuinely different pages.
- Gemini fallback trigger: always use Gemini, or only when structured extraction returns zero events?
- Timezone edge cases: how to handle pages with ambiguous times ("7pm Friday")?
- Robots.txt scope: only obey for the initial fetch, or also treat scheduled re-scrapes as separate crawls?
- Do I want a
/debug?src=... route that returns JSON of extracted events for spot-checking without opening the .ics in a text editor?
Success measures #
- I've replaced at least 3 recurring manual-copy calendar habits within a month.
- Zero duplicated events in Google Calendar after 30 days of use.
- Less than one manual intervention per feed per month.
- Total monthly cost: £0.
v1.1 — when to add state #
Trigger conditions for adding a small Netlify Blobs cache:
- Gemini free tier gets breached.
- Source pages are frequently down and I want a last-good fallback.
- I want a real preview-and-confirm flow before publishing.
- I want to track history of source changes over time.
Until any of those hit, stay stateless.