Calendar
Project88's universal calendar — Google dual-write, a default Project 88 calendar that mirrors every internal write, shareable calendars with on-behalf-of booking and visibility tiers, 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 a per-user default Project 88 calendar that mirrors every
internal write, first-class sharing with visibility tiers, reminders,
record linking, and tasks.
Calendar opens as a native shell surface — full-bleed, no React
Flow canvas underneath. See
Canvases and widgets → native apps vs canvases.
The dashboard-style calendar widgets (CalendarAgendaWidget,
CalendarMonthWidget, CalendarTodayWidget, CalendarStatsWidget,
…) remain addable to Home and any board.
Layout
CalendarWorkspace combines the mini-month picker, the My
Calendars sidebar, and the week grid into one cohesive surface
mounted by CalendarSurface (with a ReactFlowProvider wrapper). On
the native surface it runs as the same shared two-column shape Data
and Dial use — a full-height sidebar on the left, with the week
header riding the chrome's darker top bar over the inset white grid
on the right (the shared NativePaneChrome). The redundant
"Calendar" title row was dropped: the sidebar leads with the
month-nav header, and the top bar carries the week nav.
- Left — mini calendar + My Calendars — full-height sidebar with the mini-month date picker (event dots, view toggle Day / Week, event type legend) above the visibility checkboxes for every attached calendar. Clicking a day drives the week pane on the right.
- Top bar — week header — the previous / Today / next controls
plus a New event button (
Plusicon) ride the darker top bar on the native surface, so creating an event is one click without needing to drop into a slot first. - Right — schedule — day or week grid in the inset white card. Drag events to reschedule; drag across calendars to move ownership.
- Event detail sheet —
EventDetailSheetslides in from the right 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
embedded CalendarWeekWidget on a canvas keeps its single-column
chrome — the full-height-sidebar arrangement is the native-surface
opt-in (nativeLayout); canvas widgets render the week header inside
the pane and don't get a sidebar.
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, meeting_activity_id (disposition tracking), and
mirror_group_id (shared identifier linking twin rows across the
default Project 88 mirror — see below).
The default Project 88 calendar
Migration 109_calendar_default_and_mirror.sql. Every org member is
auto-provisioned a calendar called Project 88 with
is_default_project88 = true (a partial unique index enforces "at
most one default per (org, user)"). It cannot be deleted — a trigger
hard-blocks DELETE and the Remove from list action in the
sidebar rejects it.
This default is the system-of-record for mirror writes: when a
user creates an event in any other internal calendar (or in Google via
the in-app create flow), an atomic twin is also inserted into the
actor's default Project 88 calendar, sharing the same
mirror_group_id. Edits and deletes propagate to every row in the
group via create_event_with_mirror, update_event_with_mirror, and
delete_event_with_mirror.
Sync ingestion from Google is the deliberate exception: it never mirrors, otherwise every synced event would balloon into a second P88 row. Edits made directly inside Google.com's UI drift on the twin until the user touches the event from Project 88. This is a known, documented limitation.
Owners can also create extra non-default internal calendars via the
sidebar's + New calendar affordance; only the auto-provisioned
default carries is_default_project88 = true.
The default Project 88 calendar (and any shared / org-wide internal calendar) is restored to the enabled set on every load unless explicitly persisted-disabled by the user. Calendar-fetch reconciles its slice into the enabled set additively — internal and shared calendars aren't in Google's calendar-list response, so replacing the set on each fetch used to drop them and lock the checkbox off on the next reload; the reconciler is add-only, and removal happens only through user toggles.
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.
Real-time Google Calendar sync + app-wide booking toasts
Appointments booked on a user's Google calendar (via a scheduling
link, an assistant, or Google Calendar itself) surface in the
Project 88 UI within seconds and raise a toast on every app
surface — Calendar, Data, Dial, Inbox, Home, all of them — because
CalendarProvider mounts above the app shell.
Three layers, from most-immediate to worst-case fallback:
- 1. Google push (instant, server-side).
calendar-syncregisters a Googlewatchchannel per calendar (7-day TTL, renewed when<24hremain, from both the sync and the webhook paths). A newcalendar-webhookedge function receives Google's push notifications, authenticates them with a per-channel random token (verify_jwtoff since Google sends no Supabase JWT), and runs the same syncToken delta pull. Migration148adds thewatch_channel_id / watch_resource_id / watch_token / watch_expires_atcolumns plus a partial index; the sync core extracted into_shared/google-calendar-sync.tsis one implementation for both entry points. - 2. Supabase Realtime (instant, client-side, any app surface).
Migration
147addscalendar_eventsto thesupabase_realtimepublication (payloads RLS-gated by the existing org-membership policy).CalendarProvidersubscribes per org: bursts collapse into one debounced window refetch, and external Google-side changes raise a toast — new bookings as a success toast, cancellations as a warning. Self-echo is suppressed via a 60 s TTL mutation registry (localCalendarMutations) so your own edits don't toast you back. Backfill from initial / full resyncs can't toast-storm — only events Google created within the last 10 min qualify. - 3. Polling fallback (≤60 s worst case). Syncs are now
awaited and trigger a window refetch when deltas actually landed — this alone fixes the original "new bookings don't show up until you hard-refresh and edit an existing appointment" race. An additional sync fires every 60 s while the tab is visible, plus on focus / visibility change.syncTokendeltas make idle polls near-free.
If Google push registration fails (usually because the push-endpoint
domain isn't verified in the Google Cloud console — see
Google Calendar integration → push notifications),
the sync response records watch: "failed" and the app falls back
gracefully to the 60 s polling layer.
Calendar sharing, roles, and visibility tiers
Sharing has two orthogonal axes: role (viewer / editor,
who can do what) and visibility tier (full / details_only /
times_only, how much each event reveals).
org_wide boolean
When a calendar is org_wide = true, every member of the org can read
its events. Pair it with org_wide_visibility_tier (full,
details_only, or times_only) to broadcast the calendar but redact
event content for non-owners.
calendar_shares table
Per-user grants on top of the org-wide flag. Each row is
(calendar_id, grantee_user_id, role, visibility_tier). Viewers can
read; editors can read and book on behalf of the owner.
Visibility tiers
Migration 111_calendar_visibility_tiers.sql. Tier controls what the
grantee actually sees on each event row:
| Tier | What the grantee sees |
|---|---|
full | Title, description, location, attendees, linked records — everything |
details_only | Title, description, location — no attendees, no linked record |
times_only | Only start/end times — title etc. nulled, the UI renders "Busy" |
Enforcement is server-side via the v_calendar_events_with_disposition
view, which redacts column values per the caller's effective tier. The
most permissive source wins across (owner → always full, explicit
share, org_wide). Clients can't bypass via dev tools — the raw rows
are never returned.
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.
Managing sharing
Manageable calendars show a gear icon on their sidebar row that opens the calendar settings sheet — a standard 320 px right-side slide-in with the calendar's color, name, and provider badge in a sub-row below the header. From there you can flip Public to org on or off, pick the org-wide visibility tier from a dropdown, add per-user shares with a role + tier picker, and revoke individual grantees.
A calendar is manageable (isCalendarManageable in
calendarWidgetShared.js) when it's internal or a Google
calendar with accessRole === 'owner'. Read-only subscribed Google
calendars (US Holidays, Birthdays, anything where Google reports
reader access) don't get a gear — their settings sheet would 400
against the uuid-typed calendar_shares.calendar_id anyway.
For Google calendars the settings sheet hides every Project88
sharing control — the Public to org toggle, the org-wide
visibility tier picker, and the per-user calendar_shares editor —
since sharing for a Google calendar is managed in Google itself. The
sheet shows a short note plus the Remove from my calendar list
action only.
All calendars-table CRUD (org_wide flip, visibility tier, share
add / remove, hide) keys on the row's uuid (calendarUuid on the
store object). For Google calendars calendar.id is the external
Google id (often the owner's email), so the panel resolves
calUuid = calendar.calendarUuid || calendar.id — internal calendars
already carry the uuid in id. Routing CRUD through calUuid is what
makes Remove actually delete the row (the previous email-keyed call
silently no-op'd against the uuid-typed calendars.id column).
In the 320 px sheet, the What org members see row sits stacked —
label and description on top, full-width NativeSelect underneath —
so the tier label never wraps one word per line in the narrow
column.
Hiding noisy calendars
Migration 114_calendar_hidden_at.sql. The settings sheet also exposes
a destructive Remove from my calendar list action that flips a
separate hidden_at flag (distinct from deleted_at, which the
Google sync path would otherwise resurrect on conflict). Hidden
calendars drop out of every sidebar section into a collapsible
Hidden (N) group at the bottom, each row with a one-click
restore button. The read view also filters their events out of the
grid so nothing "ghosts" without a sidebar entry.
The default Project 88 calendar cannot be hidden — the RPC rejects it and the button is disabled with a hint.
Default appointment length
User-level preference (profile.preferences.calendar.default_event_duration_minutes,
sibling to week_start_day). Configured under Settings → Calendar
→ Defaults → Default appointment length with six fixed presets —
15 / 30 / 45 / 60 / 90 / 120 minutes. Defaults to 30 min when
unset (DEFAULT_EVENT_DURATION_MINUTES in
src/hooks/useDefaultEventDuration.js).
The duration funnels through every event-creation entry point via
useDefaultEventDuration → CreateEventSheet:
- Click-to-create on the week grid. The new event seeds its end time as start + default length (see the ghost preview below for the visual).
- The week widget's "New" button and any other "create event" affordance.
- The Dialer Calendar tab and record-detail bookings.
Inside the create sheet, picking a start time auto-fills the end
to start + default length — until you set an explicit end. Once
you've touched the end picker the sheet stops auto-deriving it
(endTouched state), so a deliberate end-time choice never gets
silently rewritten when you nudge the start. Editing an existing event
starts touched, so the saved end is always preserved.
minutesToTimeValue(totalMinutes) clamps the resulting end-of-day to
23:59 so a long default duration can't roll the end past midnight
while the date stays fixed.
Click-to-create ghost preview
Hovering an empty slot on a day or week column paints a dashed primary-tinted ghost block at the snapped start position — the exact shape and time of the appointment a click will create.
- Sized to the default length. Height =
default_event_duration_minutesworth of grid rows. - Snapped to 15-minute increments via
SNAP_MINUTESso the start always lands on:00 / :15 / :30 / :45. - Centered on the pointer. The cursor maps to the vertical center of the would-be block (not the top edge), so the ghost straddles the pointer instead of hanging below it.
- Labelled with the start–end range — e.g.
9:30 AM – 10:00 AM— rendered with a smallPlusglyph and truncated when the column is narrow. - Hides while hovering an existing event. Event blocks emit
onHoverStartto clear the ghost, so a stale preview never sits behind a real block. - Clamped to the visible hour range so the block never spills
past the column's
startHour/endHourbounds.
Clicking commits the click at the snapped minute: onSlotClick(date, hour, minute) hands CreateEventSheet an initialMinute alongside
the existing initialHour, so the new event opens with the exact
start the ghost showed.
ghostStartMinute and formatGhostRange are pure helpers exported
from CalendarDayColumn.jsx for tests.
Create-event sheet
CreateEventSheet is the side-sheet that opens for + New event or
when you click a slot in the schedule. Rows render top-to-bottom in
this order: Date → Time → Calendar → Location → Record → Tags →
Title → Notes.
Every meta row (Date, Time, Calendar, Location, Tags, Notes) follows the same collapsible "Add …" placeholder pattern — click the row to expand into the picker, click the X to clear and re-collapse. Date and Time clear their value on collapse; Calendar and Tags only re-collapse (the default selection survives). The All-day toggle inside the Date row removes the Time row entirely.
For new events, Date / Time / Calendar start collapsed even when they already carry a pre-filled value — the muted placeholder button renders the value inline (the clicked slot's date, the one-hour time range, the default calendar's color + name) and expands on click. This matches Google Calendar's quick-create behavior: you can hit Save on a slot click without ever opening the pickers. Edit mode still expands every row to its value, and linking a record auto-opens Tags so the record's inherited pills are visible.
The primary button reads Save with a floppy-disk icon for new events and Save Changes in edit mode.
The Notes row hosts a small Tiptap editor (EventNotesEditor) —
the same StarterKit + Link shape used elsewhere — so users edit
formatted text instead of seeing raw markdown source. Bold / italic /
links survive the round-trip back to Google.
Event descriptions: markdown end-to-end
Google Calendar's booking pages emit HTML in their event
descriptions (<b>Booked by</b>, <br>, etc.). Project 88
standardizes on markdown for the field end-to-end:
- On ingest.
calendarDescription.jsruns incoming HTML throughturndown, socalendar_events.descriptionalways holds clean markdown. The original HTML is retained in therawcolumn for rollback. - On render. Detail popovers route the column through the shared
MarkdownRendererand the agenda widget's truncated subtitle gets a plaintext flatten — no<b>or**artifacts leak into either surface. - On write back to Google.
googleEventPayload.jsre-renders the markdown to HTML withmarkedso editing a synced event preserves formatting in Google's UI.
Week start day
User-level calendar preferences live under
profiles.preferences.calendar (same JSONB column the notifications
and sidebar-order preferences use — no migration). Today the only
entry is week_start_day (0 = Sunday … 6 = Saturday), configured
under Settings → Calendar → Defaults.
Every calendar widget that lays out a week — Week, Month, Year,
Heatmap, Schedule, and the Dial Calendar tab — resolves the
day-of-week via useWeekStartDay(). The week's end is derived as
(start + 6) % 7; the legacy calendarWeekEndDay JSONB key is
ignored.
The Calendar Week widget's existing per-instance setting now
becomes an optional override — its first option reads
Inherit (Sunday) (or whatever the user-level default resolves
to), and explicit override values shift just that widget without
changing the user preference. All week boundaries use local-TZ
Date constructors so DST 23-hour / 25-hour transitions resolve
correctly.
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.
Deduplication
Reminders now fire once per event, per user, across every open
tab and every device. Two multipliers used to stack — a
create_event_with_mirror event is two calendar_events rows
sharing a mirror_group_id, and both reminder paths used to
consume the raw list (the visibleEvents memo that collapses
mirror twins for rendering sat below the reminder hooks in
declaration order, which meant they read the un-collapsed list),
and the "already reminded" set was scoped to a single tab via
sessionStorage so N open tabs each fired independently. In
production one 15:15 appointment could produce six reminders
(2 twins × 3 tabs), and 92 % of appointments in event_reminders
were duplicated.
Three layers now enforce single delivery:
visibleEventsfeeds the reminder hooks. The mirror-twin collapse runs above both reminder paths, so twins can't fire twice.- The fired-set is cross-tab. Now in
localStorage, re-read every 30 s tick so a sibling tab's writes are visible. The key carries occurrence start and prunes an hour past that — otherwise Google's practice of reusing oneevent_idacross every instance of a recurring event would silently cancel every reminder after the first. - DB-level partial unique index on
(user_id, event_id, event_start)for the calendar category (migration169) — the backstop for what no browser-local guard can reach (a second device, or a long-open tab on a stale bundle). Duplicate inserts return 23505 and are swallowed client-side. Toasts stay per-browser — someone at two machines should see the reminder on the screen they're looking at — so only the bell / DB-log path is deduped.
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.
Drag activation is gated on a 4-pixel movement threshold — a
plain mousedown / click causes zero drag preview and zero re-render
churn. This matters for stacked overlapping events: previously,
publishing a drag preview on plain mousedown could swap the two
blocks' columns before the click resolved (their layoutDayEvents
column assignment breaks ties on array order, and the preview merge
was rebuilding the array). Preview merges now happen in place via
the shared applyDragPreview helper, and column ties break
deterministically on event id, so overlapping events keep stable
columns during click and drag both.
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.
Creating a link (or updating / cancelling the linked event) also
logs a kind = 'meeting' row on the record's Activity tab.
A calendar_event_links → calendar_events.lead_row_id sync
trigger (migration 162) drives the existing 096 / 107
activity triggers; migration 163 backfilled the 543 historical
links. See
Records → calendar event lifecycle activities.
The dial-stats meeting counts, the ghosted-leads
rollup, and the
next-appointment badge
all read the same lead_row_id and start reporting real numbers
now that it's populated.
Meeting disposition on the sheet's picker applies instantly —
the sheet holds selection in local state, re-seeds from the prop
only when the prop actually changes, and rolls back on write
failure using the value-set's color / label rather than a stale
snapshot. useRecordActivities subscribes to disposition_updated
so the dial Activity timeline (and any other surface reading the
hook) patches the matching activity plus its hydrated event in
place, without waiting for a full refetch.
Tasks on the calendar
Tasks are kind = 'task' activities in the record_activities table
(see Records). Open tasks with a
due_at are UNIONed into the calendar feed by
v_calendar_events_with_disposition as virtual rows on the owner's
default Project 88 calendar — single source of truth, no duplicate
storage.
Migration 149 gives the view a source_kind discriminator
('event' or 'task'), and surfaces the task's parent record as
lead_row_id / lead_table_id (previously nulled in the union),
gated behind the same full visibility tier as meetings. The client
maps source_kind: 'task' → type: 'task', which picks up the
existing green task fill via useEventDisplayColors. Task rows on
the grid render with a distinct treatment:
- Task chips (week grid + Dial's day view) carry a dashed left
stripe and a completion checkbox that optimistically drops
the chip from the feed (open-task semantics — the view only
carries open tasks). Tasks are never draggable — the drag
commit path writes
calendar_eventsand would fail on arecord_activitiesid. - Date-only tasks render as an
AllDayTaskChipin a slim strip between the day headers and the time grid, in both the week widget and the Dial Calendar tab. (Before, the week grid's all-day filter silently dropped them.) - Clicking a task opens its parent record instead of the event detail sheet — whose edit / delete would 404 on a task id — and the tag / delete context menu is suppressed for tasks.
- Sync: task mutations from any surface (Dial's Tasks tab, the
Tasks widget, a record's Activity tab) refetch the events window
off the task bus, since the
calendar_eventsrealtime channel never fires forrecord_activitieswrites.
Tasks with no due date don't appear; completed tasks drop off, matching open-task semantics everywhere else.
The dedicated TasksWidget groups tasks into overdue / due today /
upcoming with a checkbox that toggles completed_at and removes
the row from the calendar feed. The Dial Scripts panel's per-lead
Tasks tab is the third surface — see
Dial → Tasks tab.
Embeddable widgets
The Calendar app itself is now native, but the dashboard-style calendar widgets remain canvas-addable — drop any of them on Home or your own boards:
CalendarWeekWidget(week grid with drag-to-reschedule, a New event button in the header that opensCreateEventSheeton the current day, the click-to-create ghost preview on every empty slot, and a header search that filters the week on event title- description with a small "No events match …" hint when the visible week has zero matches — in-flight drag previews and just-created optimistic rows are pinned past the filter so typing mid-drag doesn't yank the preview)
CalendarMonthWidgetCalendarAgendaWidget(Now / Next status)CalendarTodayWidget,CalendarTimelineWidget,CalendarStatsWidgetTasksWidgetDialCalendarTab(single-day column inside the Dial app)
All share calendarWidgetShared.js for time formatting, color
resolution, and the composeWeekEvents filter helper — same helpers
CalendarWorkspace uses, so the native surface and the canvas
widgets stay visually in lockstep.
Every widget paints a layout-matched skeleton placeholder on first load and on identity changes (month, week, day, folder) — see Canvases & widgets → loading states. Post-mutation refetches (create / update / delete / tag) keep the grid visible and only tick the header spinner.
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).