Workspace Modes

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 detailEventDetailSheet with 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:

ColumnPurpose
provider'google' or 'internal'
external_idGoogle event ID (when provider = 'google')
calendar_idThe owning calendar
user_idCalendar owner
created_by_user_idEvent creator (differs on on-behalf-of bookings)
start_at / end_atEvent time range
all_dayBoolean
is_virtualBoolean (driven by conference_url)
attendeesJSONB array
recurrenceRRULE-style
etag, ical_uidUsed during Google sync
rawFull 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.

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:

ColumnPurpose
event_idGoogle event ID
calendar_idGoogle calendar ID
table_idUser table the linked row lives in
row_idThe 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)
  • CalendarMonthWidget
  • CalendarAgendaWidget (Now / Next status)
  • CalendarTodayWidget, CalendarTimelineWidget, CalendarStatsWidget
  • TasksWidget
  • DialCalendarTab (single-day column inside the Dial mode)

All share calendarWidgetShared.js for time formatting and color resolution.

Connecting your calendar

⌘KSettings → 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).

Where to next

On this page