0.7.0 — Data-table totals row, formula-column filter fix, no more rows lost past the 1000-row cap
Every data-table view gets a per-column summary footer with type-aware aggregations (Sum, Avg, Min, Max, Median, Earliest, Latest, Most common, %) scoped to the filtered rows and saved per view. A view filtering on a formula column now matches; edited views no longer stay dirty forever after saving. And user tables with more than ~1000 active rows no longer silently drop the tail — allocator moves to a DB trigger, list walks the whole set with a stable tiebreaker, and the footer flags partial results.
What's new
Totals row on every data-table view
Every data-table view now carries a per-column summary footer — the Airtable / Notion pattern, one aggregation picker per column scoped to that column's type:
| Type | Menu |
|---|---|
| all | Filled, Empty, Unique, % Filled, % Empty |
number price percent formula child-link | + Sum, Avg, Min, Max, Median, Range |
date datetime | + Earliest, Latest, Range |
boolean | + Checked, Unchecked, % Checked |
select multi-select | + Most common |
Percent columns list Avg first — Sum stays available for allocation-style columns that should total 100%, but summing a rate column is almost always a mistake.
Scoped to the filtered rows, never the page. Paging can't move a total; filtering always does. So the number under a filtered view reflects that view's rows, not just the ones on screen.
Stored per view rather than per column. A view is a saved
perspective — "Won Deals" can total value where "All Leads"
counts records. Changing a summary marks the view dirty and saves
through the existing Save button; it never auto-saves. The picker
persists onto filter_presets.summaries (migration 159).
Never shows a number it can't stand behind. Results carry a
{ value, state, reason } shape:
ok— exact for the rows we have.partial— the row fetch was truncated; the value shows with a marker and a tooltip rather than passing as complete. Truncation is detected by comparing the rows returned against an exact count in the same round-trip, so it catches clipping from our own limit or PostgREST'sdb-max-rowswithout the client needing to know either.uncomputable— a formula / child-link column above the existing 5,000-row cap; renders—, not a guess.
Fixes
Filtering on a formula column now matches
A view that filtered on a plain formula column previously matched
nothing — evalTableRule only special-cased child-link formulas,
so a rule on a formula column compared against an always-undefined
row.data[key]. The filter picker offered formula columns, so this
was a filter the engine silently couldn't honor: it emptied the view
and the sidebar's record count for it. Now evaluated the same way
the sort path already did.
If the formula context isn't available at eval time (rare — a sort-only code path), the rule fails closed rather than widening a saved view to every row.
Formula values compare as text for equals / not_equals,
matching the existing child-link-formula branch. Range operators
(>, <, between) coerce numerically. So
expected_commission equals 100 won't match 100.00 — numeric
formula equality is a deliberate follow-up.
Views no longer stay dirty forever after saving
The dirty dot lingered on the title even right after Save, because
isDirty compared raw JSON including node ids, and toFilterTree
regenerates node ids on every normalization — so the post-save
re-snapshot never matched the newly-normalized live tree. The
comparison now uses the same id-stripping viewContentKey the
external-edit path already used, so Save clears the dot immediately.
User tables past ~1000 rows no longer silently drop the tail
Records created in a user table with more than ~1000 active rows saved correctly and then never appeared in the grid — they were in the database, sitting just past the fetch ceiling. Three overlapping bugs, all fixed:
- The 1000-row fetch ceiling.
userTableRows.listissued one un-ranged select, and PostgREST caps any single response at itsdb-max-rows(1000 on Supabase) regardless of the.limit()requested. The list now walks with.range()up toROW_FETCH_LIMIT, with anidtiebreaker so page boundaries stay stable whensort_ordervalues collide. - The allocator read a truncated cache.
addRowcomputedsort_orderasmax+1over the browser cache — only ever a prefix of the table. Once the real maximum was off-screen, every insert reused the same value. Allocation moves to aBEFORE INSERTtrigger (migration160) that sees every row, guarded by a per-table advisory lock. Migration161drops the column'sDEFAULT 0— Postgres applies column defaults beforeBEFORE INSERTtriggers, so the default silently pinned every new row to0before the trigger could fire. convert_rowscopiedsort_orderacross tables. It wrote the source table's value into the target table's numbering space — the origin of 27 of 27 collision groups seen in this database. Converted rows now append to the target's tail; the original value is still recorded inrecord_conversions.source_sort_order, which is whatundo_conversionrestores from.
The truncation flag was already computed and plumbed into
DataTableViewerWidget, but never rendered — the footer read
"1000 records" for a 1002-row table. Both table widgets now
mark a clipped row set as partial in the footer count, matching
the new totals-row partial state.
Under the hood
filter_presets.summaries(migration159) — jsonb map of{ [columnKey]: { agg } }scoped to the preset, so summaries save and share exactly like filters and sorts already do.user_table_rows.sort_orderallocator (migrations160+161) —BEFORE INSERTtrigger backed by a per-table advisory lock;create_record_with_childrenandconvert_rowsdefer to it. Backfill re-sequences the 27 collision groups (relative order preserved, ordered by existingsort_orderfirst). No unique index on(table_id, sort_order)—undo_conversiondeliberately restores a row to a slot another row may since have taken.- Rollup batching narrowed. The parent-id key is built only when
a child-link column exists (previously built for every table), the
rollup fetch widens when a child-link column is summarized, and
p_parent_idsis chunked at 1000. formatCellValueand friends lifted out ofDataTableViewerWidgetintolib/dataTable/formatCell— the footer needs them and the widget imports the footer, so leaving them in place would have been an import cycle. Four existing importers re-export from the new home unchanged.NUMERIC_COLUMN_TYPESshared withChildLinkConfigPanelso the two can't drift when a numeric column type is added; the menus stay separate (they genuinely differ).
What's next
- Custom summary equations —
{Revenue:sum} / {Deals:filled}, as a separate concept over column aggregates. Deliberately not in this release; shipping them first would push everyone to hand-writesum({Amount})for the common case. - Numeric formula-column equality.
equals/not_equalson a formula column compares as text today; range operators (>,<,between) already coerce numerically.
See Data app and Data tables (core concept).
0.8.0 — Templates native app + calendar events log to the record timeline (with backfill)
Templates ships as a native app — reusable email + SMS messages with a folder tree, merge fields, and attachments (inline images in email, image bubbles on SMS) — plumbed into email compose, the dial SMS tab, and the SMS app through one picker. Separately, creating a calendar event on a record finally logs an activity row on that record's timeline (with 543 historical events backfilled), which also unblocks dial-stats meeting counts, the no-show rollup, and the next-appointment badge that were all silently computing against nothing. Plus a dial meeting-disposition applies instantly, dial's linked-record chip opens the record, and Templates picks up a proper sidebar icon and lands in the right rail position.
0.6.0 — SMS becomes a native app on live PitchPrfct messaging, dial email tab searches all mail
SMS graduates from a hidden three-panel canvas widget to a first-class native app — full-height inbox rail with search + All/Unread/Starred tabs, thread pane, service picker, and real-time messages via a HMAC-verified PitchPrfct webhook → Supabase Realtime pipeline. Dial's email tab now searches all mail (not just inbox), incoming email no longer auto-marks itself read on arrival, and dialer SMS sends reuse the conversation's existing number instead of thrashing the phone-numbers API.