There are two ways to integrate a calendar into a notes app. One is to ask for read/write access, sync events both ways, become a thin shell over your existing calendar, and quietly turn into a third place your meetings might be wrong. The other is to ask for read access, render today's events above your task list, and stay out of the way.
We picked the second one. This post is about why, and what it cost us.
The scope
Vist's Google Calendar integration requests exactly one OAuth scope: https://www.googleapis.com/auth/calendar.readonly. Read-only. We can list your calendars, list events on them, and read event details. We can't create, update, or delete anything.
That's not a placeholder. The scope is enforced at Google's end — Vist's OAuth client doesn't possess the capability to write even if we change our mind. To upgrade, we'd have to re-prompt every connected user with an explicit consent screen, and we'd have to be able to explain why. We can't yet, so we don't ask.
Why this is the right default
A two-way sync between two task systems (Calendar events ↔ Vist tasks) sounds clean on a whiteboard. In practice it produces three classes of bug we don't want to own:
- Conflicts. Your phone marks a calendar event "tentative"; Vist marks it "confirmed"; one of us has to win. The user doesn't know which won. We get to email them about it.
- Drift. A Vist task with a due date and a calendar block hold the same information in two places. They desynchronise the first time the network fails mid-write.
- Permission creep. Once you have write scope, the temptation to use it grows. Auto-block focus time. Auto-decline conflicts. Auto-anything. The user's calendar belongs to the user, not to your "helpful" features.
Read-only sidesteps all three. The calendar is a view; we are not its source of truth, ever.
The cache
When you load Today, we fetch your events from Google Calendar for the current day plus the next ~24 hours. Each fetch round-trips through GoogleCalendarService and writes the result to a short-lived cache keyed by (user_id, day). The TTL is 15 minutes.
Fifteen minutes is the wrong cache duration for almost any other use case. It's wrong here too: it means a meeting you just added on your phone takes up to a quarter-hour to appear in Vist. We picked it on purpose — it's the right cache duration for rate-limit hygiene. Without it, every tab refresh would hammer the Google Calendar API, every user, every minute. With it, we hold load steady at a small multiple of "events per user per day" and stay well within free-tier quota even at high session counts.
We don't persist event bodies anywhere durable. The cache is in Rails.cache (SolidCache, in this app), and the only piece we put in the database is the OAuth refresh token — which lives in GoogleConnection, scoped per-user, encrypted at rest like every other token.
Where it surfaces
Once connected, today's events render as a small card above your task list in the Today view. The card title is the event title; the time range is on the right; meeting URLs (Zoom, Meet, etc.) become click-through. We don't try to merge events into the task stream — they're a different shape and behave differently. We just put them physically adjacent.
If you have no events today, the card hides. If you've connected the integration but haven't approved the consent screen yet, you see a thin status hint. If your refresh token expired, we re-prompt on the next page load with no other side effects.
The EU consequence
Google Calendar lives in Google's infrastructure, wherever Google routes it. Our copy of your OAuth tokens lives in Vist's database, which lives in Falkenstein, Germany, on Hetzner.
That means: the event data itself is governed by Google's processor agreement (with you, separately). The fact that you've connected — and our refresh token to act on your behalf — is governed by Vist's data residency. The two are tractable to reason about because we keep them small. There's no third place where event content lives in our system.
If you disconnect, we revoke the refresh token via Google's API and delete the GoogleConnection row. Cache evicts within the TTL window. No tombstones.
What's next
The next obvious feature is natural-language due dates that respect your availability — typing "schedule for next free afternoon" and having Vist pick a time slot that doesn't clobber an existing meeting. That requires reading availability; it doesn't require writing the result to the calendar. We can do it without expanding the scope.
The version after that — the one with write scope, the one that does block focus time — we may build. But it'll be its own opt-in, with its own consent screen, and the default will stay where it is. Read-only first. Earn the rest.