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.
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, 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 (matching EditTableSheet's chrome) 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.
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). 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. They surface on the day grid alongside events.
The dedicated TasksWidget groups them into overdue / due today /
upcoming with a checkbox toggle that flips completed_at and removes
the row from the calendar feed.
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).