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.
What's new
Templates: native app for reusable email + SMS messages
Templates ships as a native app (kind: 'native', status: LIVE)
— a folder tree of reusable email and SMS messages, plumbed into
every surface those messages actually get sent from.
- Folder tree with All / Email / SMS smart filters, drag-to-reorder, and per-row permissions.
- Channel frames — SMS renders in a 380 px phone with a bottom-anchored composer; email renders as a 720 px sheet. Edit and Preview share the frame, so toggling changes what you see (merge fields resolving, attachments rendering) not the layout. Templates open in preview — Edit is a deliberate switch.
- Merge fields —
{{contact.first_name|there}}with inline fallbacks.contact.*keys line up 1:1 with the dialer'sDIAL_SLOTS, so the dialer's active lead drops straight in. An unresolvable tag with no fallback stays visible in the inserted text rather than leaving an invisible hole in a real outbound message; the picker counts what won't fill in before you insert. - Attachments — business cards, licenses, brochures, in a new
template-attachmentsbucket (org-scoped path) with amessage_template_attachmentschild table. Server-side copy on insert — the sharedemail-apirefuses anystoragePaththat doesn't start with the sending user's id, so rather than loosen that check, inserting a template server-side copies each file into the draft's own prefix. No bytes cross the browser, the send path stays untouched, and the draft is self-contained — editing the template later can't alter an email already queued. - Inline images in email under 1 MB, dropped exactly where you
want them via Tiptap's
posAtCoords→insertContentAt. SVG is excluded from inline (script surface + several clients strip it) but stays allowed as a regular attachment. - Images-as-bubbles on SMS. On the SMS side the pictures are the message — they render as bubbles in the phone frame, and Copy message carries them as base64 data URIs so a paste into the Messages app reproduces the template exactly.
- Permissions. Read/write: any org member. Delete: creator
or org admin — chosen between the
pagesrule (admin only, so a rep can't clean up their own drafts) and "any member" (one careless drag takes out the team's library). Folder deletes send an explicit descendant id list rather than leaning on the FK cascade, so RLS evaluates per row and refusals are reported.
Live and in use — folders and templates were created against prod while this was built.
Where templates get used — one TemplatePickerPopover behind
three call sites:
- Inbox → template insert — body inserts at the cursor, subject fills only when empty.
- Dial → SMS tab — the per-lead composer.
- SMS app — the shared thread composer.
Both SMS surfaces share one SmsComposer component, so a picker
fix or a merge-field addition lands in both immediately.
See Templates app.
Calendar events log to the record timeline (with backfill)
Creating a calendar event on a record now logs an activity row
in that record's timeline — with an inline preview of the event
that opens the full EventDetailSheet on click. Existing history
is backfilled: 543 activities across 383 records spanning
Mar 2026 → today, deduped and idempotent.
The feature had been half-built and completely dead. Migration
096 creates a kind='meeting' activity when an event is linked
to a record, and 107 adds reschedule/rename/cancel follow-ups on
top. Both key off calendar_events.lead_row_id — which the app
never wrote. Record ↔ event links go through the separate
calendar_event_links table. Prod: 555 links, 0 events with
lead_row_id, 0 with meeting_activity_id — neither trigger
had fired once since it shipped.
The same null column is also read by the ghosted-leads /
no-show rollup
(migration 122), the dial-stats meeting counts
(migration 092), the next-appointment badge
(migration 124), and the disposition-stripe view — so those
have been silently computing against nothing the whole time.
They work now. Expect meeting figures in the dial stats widget to
jump; that's the fix landing, not a regression.
Under the hood:
- Migration 162 — wire it up. A
calendar_event_links → calendar_events.lead_row_idsync trigger, sosaveEventLink(the one choke point every create/edit path funnels through) fires the existing096/107triggers. Server-side and automation writes are covered too. A newrecord_activities.calendar_event_idback-pointer fills in what096missed.occurred_atusesnow()rather thanstart_at, so a meeting booked for next week no longer pins itself to the top of the record's feed forever. - Migration 163 — backfill. 543 activities re-created from
the existing links, with historical stamps set explicitly.
Runs with the
096snapshot trigger disabled for the duration: that trigger hardcodesnow()for both timestamps, so routing a backfill through it would stack all 543 activities at the migration instant and resetlast_activity_at(the dial-queue ordering column) across all 383 records at once. With timestamps set explicitly, the bump's own monotonic guard applies instead — only 29 of 383 records advance, each to a genuine meeting time. ActivityCardrenders the event preview as its own button, clicking opens the sharedEventDetailSheet(with Edit, Delete, and undo intact) rather than a second weaker preview.listByRowbatch-hydrates the linked event in one query, mirroring the existing profile join — no N+1 on a 50-row timeline.- New
· logged Xm agostamp appears only whencreated_atdiverges fromoccurred_atby more than a minute, so a backdated entry never conflates the two. Visual no-op for existing rows.
Trigger audit run before the backfill — automation emit trigger
(no active event-trigger automations), DNC/suppression rules
(early-return unless kind='call'), 107 lifecycle (early-return
on a lead_row_id change), the no-show rollup (only counts
no_showed; backfill writes pending) — none fired spuriously.
Both migrations dry-run against prod inside an aborting
transaction before apply.
See Records → calendar event lifecycle activities.
Fixes
Dial: meeting disposition applies instantly, linked-record chip is clickable
Two defects on the dial screen's meeting-event preview, reported as "the disposition doesn't load immediately and then I can't open the linked record."
- The picked disposition didn't show.
EventDetailSheet's select was bound straight to theeventprop — a snapshot owned by whichever surface launched the sheet (here, the dial Activity timeline). The write fanned out on the activity bus (so the calendar chip repainted) but nothing fed a fresh event object back into the sheet, so the trigger kept reading "— No disposition —" until a refetch landed. The sheet now holds the selection in local state, re-seeds from the prop only when the prop actually changes, and rolls back on a failed write using the value-set's color / label rather than the stale snapshot (which previously could revert a cleared disposition to a non-null color). - The timeline card stayed stale too. The sheet writes
record_activitiesdirectly, bypassinguseRecordActivities, and that hook only listened for row-bumps. It now subscribes todisposition_updatedand patches the matching activity plus its hydrated event in place. The existing cache-mirror effect carries the update into the lead's cache slot, so switching leads and back doesn't resurrect the old value. - The linked-record chip was dead text.
DialActivityPanelmounted the timeline withoutonOpenRecord, soEventDetailSheetfell back to its read-only<span>branch.DialerBody→DialActivityPanel→DialActivityTabnow threadonOpenRecord, which also makes linked-record chips on ordinary activity rows clickable there.
No schema changes.
Templates: sidebar icon + landing position
Templates was already live in prod (APP_CANVAS_CONFIG.templates
was status: LIVE, inSidebar: true, kind: 'native', the native
surface was registered, the DB and bucket were in place) — it was
just hard to see, for two reasons:
- It had no icon.
SIDEBAR_ICON_MAPinIconSidebar.jsxnever gotLayoutTemplate, so Templates fell through to the genericLayoutGridfallback and looked like a placeholder. Adds the mapping, plus a contract test asserting everyinSidebarapp'siconNameresolves in the map — a missing name silently degrades instead of failing, so it now fails. - It landed at the bottom of the rail. Sidebar order is
persisted per user in
profile.preferences.sidebar_order. Both profiles that had one predate Templates, and the old merge appended unknown apps after the entire saved list — so every app shipped since a user last dragged the rail stacked up at the bottom, ignoring itssidebarOrder. New pure helpermergeSidebarOrdersplices each unsaved app in after the last saved item it outranks: the user's manual arrangement still wins, but a new app lands where the product intends rather than in the basement.
Under the hood
- Migration
162_message_templates— tree table, RLS, realtime, Data API grants. - Migration
163_message_template_attachments— bucket + child table + storage policies (creator-or-admin delete via metadata row). - Migration
164_message_template_attachment_placement— where media sits relative to the text. - Migrations
162/163for calendar activity —calendar_event_links → calendar_events.lead_row_idsync trigger + backfill. (Yes, both features chose162/163in parallel; they landed on separate migration streams — the message-templates stream and the app-DB stream — so the numbers don't collide at apply time.) lib/treeSortOrder— sort-order bisection + renumber-on-collapse, extracted fromstore/pages.jsxand now shared by Templates and Pages. The 51 existing pages-folder tests kept passing unchanged.lib/attachmentLimits— the 25 MB caps andformatBytes, shared with the emailcomposeStore.lib/calendarEventShape— event normalizers extracted so the API layer can share them without anapi → storeimport cycle.ActivityStreamWithEventswrapper keepsActivityStreamfree of the Calendar / Tags providers; used by both the record sheet and the dialer.sidebarOrder.js— themergeSidebarOrderpure helper described above, plus 9 new tests (icon-map contract + 8 merge cases: drops dead pages, splices at rank, several new apps keep relative order, no mutation, empty saved order).- Dead-code cleanup. The unrouted
HomePreviews.jsxdesign-comparison harness — never wired to a route, and since the sidebar-order fix it looked like a live consumer of the app registry — was removed along with its test.buildAppTileswent with it;visibleAppNavItemskeeps its real consumer inGlobalCommandBar. Registry invariants that matter (status,kind,sidebarOrder, icon coverage, native-surface mapping) stay covered byregistryContract.test.jsandIconSidebar.iconMap.test.jsx.
What's next
- MMS on the dial / SMS composer. The Templates picker attaches and previews media on SMS templates, and Copy message carries the bytes — but the outbound path is text-only until MMS support is confirmed on the PitchPrfct client.
- Shared asset library. The same license PDF on a dozen templates is stored a dozen times today. Fine at megabytes; revisit if collateral grows.
- Recursive folder duplication on the templates tree (Duplicate is offered on templates only today).
CalendarWeekWidgethas its own self-contained create / edit path outsideuseEventActions. Events created there still get the activity (the DB trigger doesn't care) but an open record sheet won't live-refresh in that moment — it's correct on next open. Folding the widget into the shared hook is a bigger refactor than this warranted.
See Templates app, Records → calendar event lifecycle activities, and Dial → SMS tab.
0.9.0 — Dial queue pacing + freshness mix + email compose from the dialer + disposition rollup columns
The dial queue gets two opt-in gates — an **attempt-pacing ladder** that rests a lead after N calls (and can retire past a hard cap), and a **freshness mix** that interleaves aged leads into the working queue — plus an honest "N calls today" header count and a `12 calls` history chip on the contact panel. The dialer's Email tab picks up a **New Email** button that seeds the floating compose against the active lead (with templates that finally resolve `{{contact.*}}`, and a real Forward), and the Message button opens a **template menu** for SMS with an OS-handoff. Records get **most-recent-disposition rollup columns** — three scopes, all backed by one system-field registry — so buckets, filters, and the grid can address the same signal the eligibility engine already saw. Plus templates copy an SMS picture as a picture (and paste one in), calendar reminders stop firing per-tab and per-mirror-twin, and a signup-breaking `handle_new_user_org` regression is fixed.
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.