Apps

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 (Plus icon) 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 sheetEventDetailSheet slides 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:

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, 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:

TierWhat the grantee sees
fullTitle, description, location, attendees, linked records — everything
details_onlyTitle, description, location — no attendees, no linked record
times_onlyOnly 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 useDefaultEventDurationCreateEventSheet:

  • 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_minutes worth of grid rows.
  • Snapped to 15-minute increments via SNAP_MINUTES so 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 small Plus glyph and truncated when the column is narrow.
  • Hides while hovering an existing event. Event blocks emit onHoverStart to 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 / endHour bounds.

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.js runs incoming HTML through turndown, so calendar_events.description always holds clean markdown. The original HTML is retained in the raw column for rollback.
  • On render. Detail popovers route the column through the shared MarkdownRenderer and the agenda widget's truncated subtitle gets a plaintext flatten — no <b> or ** artifacts leak into either surface.
  • On write back to Google. googleEventPayload.js re-renders the markdown to HTML with marked so 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.

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). 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 opens CreateEventSheet on 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)
  • CalendarMonthWidget
  • CalendarAgendaWidget (Now / Next status)
  • CalendarTodayWidget, CalendarTimelineWidget, CalendarStatsWidget
  • TasksWidget
  • DialCalendarTab (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

⌘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