Calendar
Project88's universal calendar — Google dual-write, shareable calendars with on-behalf-of booking, reminders, record linking, and tasks on the schedule.
Calendar is the scheduling surface. Behind the scenes it's a
universal calendar_events store that dual-writes to Google Calendar,
with first-class sharing, reminders, record linking, and tasks.
Layout
CanvasCalendarCard renders three side-by-side cards:
- Left — mini calendar + sidebar — date picker with event dots, view toggle (Day / Week), event type legend, calendar visibility checkboxes.
- Center — schedule — day or week grid. Drag events to reschedule; drag across calendars to move ownership.
- Right — event detail —
EventDetailSheetwith title, tags, time, location, description, attendees, linked record, and (if linked) a disposition picker for meeting outcome.
ViewDropdown in the breadcrumb mirrors the view toggle.
The calendar_events table
Migration 066_calendar_universal_store.sql. Every event — whether it
originated in Project88 or in Google — lives here. Key columns:
| Column | Purpose |
|---|---|
provider | 'google' or 'internal' |
external_id | Google event ID (when provider = 'google') |
calendar_id | The owning calendar |
user_id | Calendar owner |
created_by_user_id | Event creator (differs on on-behalf-of bookings) |
start_at / end_at | Event time range |
all_day | Boolean |
is_virtual | Boolean (driven by conference_url) |
attendees | JSONB array |
recurrence | RRULE-style |
etag, ical_uid | Used during Google sync |
raw | Full Google payload for round-trips |
Later migrations add lead_row_id / lead_table_id (Dial linkage),
attendance_status, and meeting_activity_id (disposition tracking).
Google dual-write
Local edits go to calendar_events first via the upsert_calendar_events
RPC, then mirrorGoogleEventUpsert() in src/services/calendarSync.js
pushes the change to Google. Deletes call mirrorGoogleEventDelete().
Inbound changes are reconciled by the calendar-sync Edge Function,
which uses Google's syncToken to fetch only the events that changed
since the last poll. Every minute, calendar-reminders processes pending
reminder rows.
A view (v_calendar_events_with_disposition) LEFT JOINs events to
record_activities so the disposition stripe and "linked record" badge
render with one query.
Calendar sharing & grants
Migration 098_calendar_sharing.sql adds two things:
org_wide boolean
When a calendar is org_wide = true, every member of the org can read
its events. Backfilled true on rollout to avoid disruption; flip to
false to make a calendar truly private to its owner.
calendar_shares table
Per-user grants on top of the org-wide flag. Each row is
(calendar_id, grantee_user_id, role) where role is 'viewer' or
'editor'. Viewers can read; editors can read and book on behalf of
the owner.
On-behalf-of booking
When an editor creates an event on someone else's calendar, the row's
user_id stays as the owner (whose calendar it lives on) but
created_by_user_id is set to the editor. EventDetailSheet shows a
"Booked by" badge when these differ, so the owner can see who
scheduled what.
This is the foundation for assistants and team members managing each other's calendars without sharing credentials.
Manage who can see and book on each calendar from ⌘K → Settings → Calendar → Sharing: an org-wide toggle, a member picker with role chooser (viewer / editor), and revoke buttons per grantee.
Deep links from the command palette
Calendar events show up as their own group in the global ⌘K
command palette. Selecting one opens the EventDetailSheet directly
on top of whatever canvas you're on — CalendarDeepLinkHost watches
the ?event=<uuid> query param, fetches the event, and renders the
existing sheet. Closing the sheet drops the param with replace so
the back button doesn't re-open it. See
Quickstart → command palette for
the full list of indexed kinds.
Event reminders
The event_reminders table (migration 051_calendar_reminders.sql)
stores pending reminders with remind_at, channels (e.g.
['email', 'push']), and a status (pending / sent / cancelled).
A partial index on remind_at WHERE status = 'pending' backs the cron
query. The calendar-reminders Edge Function runs every minute,
processes due rows, sends email and/or push (web-push with VAPID), inserts
a notification row, and marks the reminder as sent.
Cross-calendar move
Drag an event from one calendar's column to another to change its
owning calendar. CalendarWeekWidget wires useEventDrag() with cross-day
support; on drop it calls updateEvent({ calendarId, eventId, start, end, allDay }). The dual-write layer handles deleting from the source Google
calendar and creating in the destination.
Event ↔ record linking
Migration 048_calendar_event_links.sql adds a join table:
| Column | Purpose |
|---|---|
event_id | Google event ID |
calendar_id | Google calendar ID |
table_id | User table the linked row lives in |
row_id | The linked row |
EventDetailSheet shows the linked record with a quick-jump button. Records show the inverse on their Events tab (see Records).
Tags also flow across the link — tag the record and the event picks up the tag; tag the event and the record + sibling events pick it up. See Tags — two-way sync.
Tasks on the calendar
Tasks are kind = 'task' activities in the record_activities table
(see Records). The dedicated TasksWidget
groups them into overdue / due today / upcoming with a checkbox
toggle. Tasks with a due_at also surface on the day grid alongside
events.
Widgets
The Calendar mode is a canvas — drop any of these widgets:
CalendarWeekWidget(week grid with drag-to-reschedule)CalendarMonthWidgetCalendarAgendaWidget(Now / Next status)CalendarTodayWidget,CalendarTimelineWidget,CalendarStatsWidgetTasksWidgetDialCalendarTab(single-day column inside the Dial mode)
All share calendarWidgetShared.js for time formatting and color
resolution.
Connecting your calendar
⌘K → Settings → Connections → search "Google Calendar" → Connect.
OAuth → token in Supabase Vault. The mode is populated immediately and
agents can use the calendar tools (see
Google Calendar integration).