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
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:
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.
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:
| Column | What 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:
- 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