Changelog

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's DIAL_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-attachments bucket (org-scoped path) with a message_template_attachments child table. Server-side copy on insert — the shared email-api refuses any storagePath that 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 posAtCoordsinsertContentAt. 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 pages rule (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:

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_idwhich 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_id sync trigger, so saveEventLink (the one choke point every create/edit path funnels through) fires the existing 096 / 107 triggers. Server-side and automation writes are covered too. A new record_activities.calendar_event_id back-pointer fills in what 096 missed. occurred_at uses now() rather than start_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 096 snapshot trigger disabled for the duration: that trigger hardcodes now() for both timestamps, so routing a backfill through it would stack all 543 activities at the migration instant and reset last_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.
  • ActivityCard renders the event preview as its own button, clicking opens the shared EventDetailSheet (with Edit, Delete, and undo intact) rather than a second weaker preview.
  • listByRow batch-hydrates the linked event in one query, mirroring the existing profile join — no N+1 on a 50-row timeline.
  • New · 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. 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 the event prop — 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_activities directly, bypassing useRecordActivities, and that hook only listened for row-bumps. It now subscribes to disposition_updated and 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. DialActivityPanel mounted the timeline without onOpenRecord, so EventDetailSheet fell back to its read-only <span> branch. DialerBodyDialActivityPanelDialActivityTab now thread onOpenRecord, 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_MAP in IconSidebar.jsx never got LayoutTemplate, so Templates fell through to the generic LayoutGrid fallback and looked like a placeholder. Adds the mapping, plus a contract test asserting every inSidebar app's iconName resolves 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 its sidebarOrder. New pure helper mergeSidebarOrder splices 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 / 163 for calendar activitycalendar_event_links → calendar_events.lead_row_id sync trigger + backfill. (Yes, both features chose 162/163 in 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 from store/pages.jsx and now shared by Templates and Pages. The 51 existing pages-folder tests kept passing unchanged.
  • lib/attachmentLimits — the 25 MB caps and formatBytes, shared with the email composeStore.
  • lib/calendarEventShape — event normalizers extracted so the API layer can share them without an api → store import cycle.
  • ActivityStreamWithEvents wrapper keeps ActivityStream free of the Calendar / Tags providers; used by both the record sheet and the dialer.
  • sidebarOrder.js — the mergeSidebarOrder pure 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.jsx design-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. buildAppTiles went with it; visibleAppNavItems keeps its real consumer in GlobalCommandBar. Registry invariants that matter (status, kind, sidebarOrder, icon coverage, native-surface mapping) stay covered by registryContract.test.js and IconSidebar.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).
  • CalendarWeekWidget has its own self-contained create / edit path outside useEventActions. 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.

On this page