Core Concepts

Records

A record is a row in a data table — but the detail UI treats it as a first-class entity with a unified Activity stream, linked events, and child records.

A record is a single row in a user data table. The underlying storage is just a user_table_rows row, but the UI promotes records to first-class status: clicking a row opens the Record Detail Sheet, a rich right-side drawer with tabs for details, linked events, activity, and child records.

The Record Detail Sheet

src/modals/RecordDetailSheet.jsx is a right-side drawer with two modes:

  • View mode — read-only field display via ViewModeBody.
  • Edit mode — buffered editing. Field changes accumulate in an editBuffer and only commit on Save Changes, so partial edits never hit the database.

The header now sits in two rows:

  • Title row — the row's primary label + a Close (X) button. Close is sheet navigation, so it stands alone.
  • Tag strip — the row's tags (always editable via <TagPicker entityType="user_table_row" />) alongside the Edit (pencil) and Archive actions. The destructive button is now an Archive icon — clicks soft-archive the row through the same archive RPC the bulk toolbar uses (see Data tables → Archive vs. delete). The two-click confirm guard is unchanged.

In edit mode, Cancel + Save Changes replace Edit and Archive in the title row; the tag strip and tab row stay put.

Three tabs:

Details

A two-column grid of the row's fields, plus inline sections for linked records and child records and read-only system columns (created_at, updated_at, last_activity_at).

Events

Calendar events linked to this record via the calendar_event_links join table. Each row in calendar_event_links joins (event_id, calendar_id) to (table_id, row_id). The tab lists linked Google Calendar events with title, date, time, and location, and a + New event button creates a new event already linked.

Activity

A unified, polymorphic timeline rendered by the shared <ActivityStream> component. There is no separate Notes tab — notes are first-class activity entries alongside calls, emails, meetings, SMS, tasks, and generic events.

The composer is two-mode:

  • Quick note — a textarea at the top of the stream. Hit Enter (or Cmd-Enter) to commit; drafts persist in localStorage via useActivityDraft so a half-typed thought survives a reload.
  • Kind icon row — a strip of ghost IconButtons underneath the textarea, one per loggable kind (call / email / meeting / sms / task / other). Clicking an icon expands the composer into the full form with that kind preselected, replacing the old single "Log more…" expander. Cancel collapses without clearing the textarea draft.

The full form is single-row: kind dropdown + disposition picker side-by-side, body textarea below, due-date picker when relevant. There is no separate Subject field — the body carries the activity text. Disposition pickers are click-to-clear: re-clicking the currently-selected pill in the dropdown fires onChange('') and reverts the trigger to Empty, powered by a clearable prop on the shared <Select> (also wired through to the calendar event sheet). Focus styling lives on the composer container via focus-within, gated by :not(:focus-within) so hover during focus doesn't tint the border under the focus ring.

Each card shows who (display name + avatar from a profiles join allowed by the profiles_read_same_org RLS policy), when, and what. Cards are memoised with a custom comparator so typing in the composer doesn't re-render the stream. The feed is server-paginated with Load older when the row has hundreds of activities.

Activities live in the record_activities table with a kind discriminator — call, email, meeting, sms, task, note, other. A central catalog (src/lib/activityKinds.js) keeps the client-side enum in sync; the DB-level CHECK constraint still owns the authoritative boundary, and a BEFORE INSERT/UPDATE trigger validates disposition_id × kind server-side so the dial RPC isn't the only enforcer.

Calendar event lifecycle activities

When a linked calendar event is updated, cancelled, or its attendance flipped, an AFTER UPDATE/DELETE trigger on calendar_events emits a kind = 'meeting' activity onto the linked record's stream — with a per-column diff in the body and an OF clause that short-circuits identical UPDATEs so Google sync polls don't storm the timeline. The result: the activity feed always reflects what the record's meetings actually did, without the operator having to log anything by hand.

Cross-widget sync

Activity writes go through a unified activityBus so other widgets listening on the same row (the dialer column, a different open Record Detail Sheet) re-render in real time. The legacy rowActivityBus / taskBus channels stay as compat shims.

last_activity_at

Every row has a last_activity_at column managed by a Postgres trigger (migration 064_last_activity_at.sql). It bumps — never backwards — on:

  • INSERT to record_activities for that row.
  • INSERT to tag_assignments with entity_type = 'user_table_row' for that row.

An index on user_table_rows(table_id, last_activity_at DESC) makes "records sorted by recency" cheap. The viewer can surface this column; filter presets can target it.

last_open_no_show_at

A second trigger-managed system column (migration 122) holds MAX(calendar_events.start_at) for no_showed events on the row that haven't been superseded by a later attended event or a later upcoming pending event. The value clears to NULL the moment the lead recovers (attended) or rebooks (upcoming pending), so the column tracks open ghosts only — calling someone you've already rebooked is bad UX.

A partial index (table_id, last_open_no_show_at DESC) WHERE NOT NULL keeps the index lean since most leads never ghost. The range filter operator pairs with this column to drive recovery-style dial buckets like Ghosted-leads buckets.

Child-record navigation

Records can have child records in other tables, declared via child-link columns. The Detail Sheet shows each child group inline (table name, row count, + Add button) and clicking a child card opens that child's own Detail Sheet — with the parent's sheet sliding back. The onBack prop on RecordDetailSheet powers this nested navigation.

The discovery logic (findIncomingLinkedRows in src/modals/recordEditor/childTableDiscovery.js) scans every table in the org for columns that link back to this row.

Bulk convert

ConvertRecordsModal (src/modals/convertRecords/ConvertRecordsModal.jsx) maps records from one table to another. Three tabs:

  1. Map Fields — choose how source columns map to target columns. autoMap() uses label / key heuristics to seed the mapping. While the template + auto-map resolves, a SkeletonRegion mirrors the field-mapping shape (bordered card + six FieldRow-shaped rows) so the section never paints as a blank gap. The skeleton is keyed on targetTableId via useSkeletonGate, so switching targets re-shows it with a min-display floor.
  2. Options — three groups: References (inherit_tags, default on, carries record tags across), Activities, and Tags. The new Activities section between them controls relink_activities — default on, and auto-armed when delete_source is enabled so users don't silently lose history. Turning it back off in that combination surfaces a destructive warning. delete_source itself still removes source rows after a successful convert.
  3. Preview & Run — preview the first N rows, including an Activities stat row and a cascade warning when delete=on && relink_activities=off. Validation errors render inline with a retry button (so transient ERR_NETWORK_CHANGED / Failed to fetch mid-validate doesn't strand the user).

Capped at 1000 rows per run. Backed by RPCs in migration 056_record_conversion_rpcs.sql with per-batch tracking. Migration 123_record_conversion_activities.sql adds the per-activity link columns and the conversion-batch audit trail (link_table_id, link_row_id, conversion_batch_id on record_activities; kind CHECK expanded with 'conversion').

convert_rows writes two kind = 'conversion' audit activities per batch:

  • "Converted from <display>" on the new row, with link_* pointing at the source.
  • "Converted to <display>" on the source row when delete_source = false (skipped when the source is about to be deleted), with link_* pointing at the new row.

These audit activities are system-owned: 'conversion' is excluded from LOGGABLE_KINDS, the manual delete button is hidden in ActivityCard, and they're cleared only via undo_conversion. ActivityCard renders an Open linked record button when link_* are set, so the conversion trail is one click to either direction.

When relink_activities is on, convert_rows also moves the source row's existing record_activities to the new row, tracking the moved ids in the batch's audit JSONB so undo_conversion can restore them. validate_conversion now returns refs.activities and a warnings.delete_without_relink_will_cascade_activities flag for the preview UI.

Where to next

On this page