Records
A record is a row in a data table — but the detail UI treats it as a first-class entity with tabs for events, activity, notes, 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 events, activity, notes, and linked / 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 always shows:
- The display title (the row's primary label column)
- The row's tags (always editable via
<TagPicker entityType="user_table_row" />) - Save / Edit / Close buttons
Then four 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 polymorphic timeline rendered by RecordActivityTab. Activities live
in the record_activities table with a kind discriminator —
call, email, meeting, sms, task, note, other. The Dial mode
writes call activities here, the Inbox writes email activities, etc.
Notes
A focused note composer (Cmd-Enter to submit). Notes are stored as
kind = 'note' rows in record_activities, so they share the same
timeline as everything else but get their own tab for quick capture.
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.
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
workspace 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. - Options —
inherit_tags(default on) carries record tags across;delete_sourceremoves source rows after a successful convert. - Preview & Run — preview the first N rows, then run.
Capped at 1000 rows per run. Backed by RPCs in migration
056_record_conversion_rpcs.sql with per-batch tracking.
Where to next
- Tags — how records get tagged
- Data tables — the storage layer
- Pipeline stage widget — kanban over records
- Calendar mode — events linked to records