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
editBufferand 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 samearchiveRPC 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
useActivityDraftso 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:
INSERTtorecord_activitiesfor that row.INSERTtotag_assignmentswithentity_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:
- 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, aSkeletonRegionmirrors the field-mapping shape (bordered card + sixFieldRow-shaped rows) so the section never paints as a blank gap. The skeleton is keyed ontargetTableIdviauseSkeletonGate, so switching targets re-shows it with a min-display floor. - Options — three groups: References (
inherit_tags, default on, carries record tags across), Activities, and Tags. The new Activities section between them controlsrelink_activities— default on, and auto-armed whendelete_sourceis enabled so users don't silently lose history. Turning it back off in that combination surfaces a destructive warning.delete_sourceitself still removes source rows after a successful convert. - 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 transientERR_NETWORK_CHANGED/Failed to fetchmid-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').
Activity relink and audit activities
convert_rows writes two kind = 'conversion' audit activities
per batch:
- "Converted from
<display>" on the new row, withlink_*pointing at the source. - "Converted to
<display>" on the source row whendelete_source = false(skipped when the source is about to be deleted), withlink_*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
- Tags — how records get tagged
- Data tables — the storage layer
- Pipeline stage widget — kanban over records
- Calendar — events linked to records