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

Creating a calendar event on a record now logs a kind = 'meeting' activity row on that record's timeline (migrations 096 + 162) — with an inline event preview as its own button that opens the shared EventDetailSheet (Edit / Delete / undo intact) rather than a second weaker preview. When a linked calendar event is later updated, cancelled, or its attendance flipped, an AFTER UPDATE/DELETE trigger on calendar_events emits follow-up meeting rows (migration 107) 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.

occurred_at is set to now() rather than start_at, so a meeting booked for next week doesn't pin itself to the top of the record's feed until its date passes; the meeting time is rendered from the event instead. A · logged Xm ago stamp appears only when created_at diverges from occurred_at by more than a minute, so a backdated entry never conflates the two.

Bridge trigger and backfill. Migration 096 keyed off calendar_events.lead_row_id, but the app wrote record ↔ event links through the separate calendar_event_links table — so neither 096 nor 107 had fired since they shipped, and the timeline stayed empty of meeting rows. Migration 162 adds a calendar_event_links → calendar_events.lead_row_id sync trigger (hung off the one saveEventLink choke point every create/edit path funnels through), plus a record_activities.calendar_event_id back-pointer for the timeline to hydrate the linked event without a per-row lookup. Migration 163 backfilled 543 activities across 383 records from the existing links; the same fix also unblocked the downstream views that read the same null column — the dial-stats meeting counts, the no-show rollup, the next-appointment badge, and the disposition-stripe view — which had been silently computing against nothing.

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.

Most-recent-disposition rollups

Three sets of system columns land on every record in the org (migration 167), so the table viewer, the filter engine, and dial buckets can address the same signal the eligibility engine already saw. Three scopes, because they answer different questions:

ColumnWhat it's for
last_disposition_{id,at,kind,category}Record-header field — the most recent disposition across every kind of activity. Weak for bucket rules, because logging an email clobbers the call outcome.
last_call_disposition_{id,at}The one dial buckets actually want.
last_meeting_disposition_{id,at}Orthogonal to call outcome — meeting dispositions ride on the linked meeting activity, so no calendar read is needed.

Recompute-from-scratch on write, not the monotonic bump last_activity_at uses — because a forward-only bump goes stale on all four of: a backdated occurred_at, a disposition_id cleared to NULL, the activity deleted, and the value_set_item deleted (which nulls disposition_id via ON DELETE SET NULL). One trigger drives both the call rollup and the any-kind rollup — two independent triggers over the same rows can drift. Verified against production: 879 rows with dispositioned activity, agreeing exactly with lead_call_summary and an independently written aggregate.

Opt-in per table for the grid slot. Lead-shaped columns land on every table but only earn a grid slot on the tables that get dialed. Filters and dial buckets can use them either way. Toggle them from Edit table → Activity Columns — turning a column off writes enabled: false rather than dropping a real DB column, so the flag is reversible. last_open_no_show_at (above) was retrofitted with the same optional flag on this pass.

Chips resolve by id across every disposition set. A disposition column stores a bare item id — the any-kind column takes whichever kind was last — so the chip in the grid or on the record sheet resolves against every dispositions.* value set rather than pinning to one set id. Filter values are picked by label, not by pasting a uuid.

Pair the rollups with the range filter operator to build buckets like Warm — no call in 14 days (Last call disposition · is · Interested, AND Last call at · not in period · Last 14 days).

One system-field registry

Which keys name a physical column was hardcoded in four plpgsql functions and two JS constants. A field missing from any one of them fails silently: the predicate falls through to data ->> key, always NULL for a real column, so the rule matches nothing and the sort does nothing. Migration 168 replaces the copies with app_hidden.system_field_kind(), which returns each field's storage class (uuid / timestamptz / text) or NULL — the type matters because only timestamptz columns can back a date comparison or a ::timestamptz sort cast. SYSTEM_COLUMNS in src/lib/utils.js is the JS mirror; dialer_next_leads also returns every column in the registry so useDialQueue's keyset cursor can read sort keys off the row (sorting on a column the RPC doesn't return would page wrong rather than fail). Parity verified against production: 46 / 46 trees produce identical memberships in the JS engine and in filter_tree_predicate.

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