Changelog

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.

What's new

Dial queue: attempt pacing, freshness mix, and an honest header count

Two opt-in bucket features and a header rewrite that answers three separate flavours of the same complaint — "I've been calling this person for three months, but I know I have people in here that are more recent." Both features are off by default (empty ladder + empty mix); every existing bucket is byte-identical to migration 153 until someone turns them on.

1 · Attempt pacing — dial_buckets.call_cadence. A ladder of tiers, each of which says "once a lead has been called this many times, rest it for this long before it's callable again":

[{"after_calls": 0,  "rest": "P1D"},
 {"after_calls": 3,  "rest": "P3D"},
 {"after_calls": 8,  "rest": "P14D"},
 {"after_calls": 20, "retire": true}]

The highest tier at or below a lead's lifetime call count wins; retire: true at the top of the ladder is a hard stop. Suppression is pure — evaluated at queue time, no writes — so clearing the ladder instantly restores rows, matching the eligibility engine's contract. Pinned rows and header-searched leads bypass it exactly as they bypass cap, cooldown, and leases. Measured on the live YesToQuote bucket: the standard ladder above dropped the 20+ attempt leads (was 8, now 0); a retire at 8 variant cut the 20+ leads and the 8+ leads to zero, at the cost of a smaller queue.

2 · Freshness mix — dial_buckets.queue_mix. Reads as "every Nth call comes from leads older than X days":

{"aged_after_days": 30, "every_nth": 3}

No SQL for the split — dialer_next_leads already accepts a filter tree, so useDialQueue pages two streams through the same RPC (the view AND'd with created_at is_after / is_before a cutoff) and interleaves the pages. Cap, cooldown, the ladder, leases, schedule, and eligibility apply per stream, untouched. Because the streams partition the same set, dialer_queue_stats.matching_count stays correct and the mix can never surface a lead the unmixed queue would have refused. The interleave anchor is calls made, not row position — the operator always sits at the head of a queue whose head is consumed every call, so a pattern on array index would park the aged lead at slot N forever and never reach it.

3 · Header count. The daily-progress bar is replaced by a plain "N calls today". The bar measured progress against matching_count × cap — a synthetic budget that moved whenever the queue did, so it could run backwards — and its numerator was clamped per record, understating the day whenever a lead was dialed past the cap. The count now reads the unclamped per-bucket total that already backs the bucket dropdown, so the two can't disagree.

4 · Call-history chip on the contact panel. Reads 12 calls, or Aged · 12 calls · created 3mo ago when the mix served the lead from the aged stream — so when an old lead surfaces, the operator can see the queue chose it deliberately. A header-searched lead comes from the store with no count and renders as unknown rather than as zero.

See Dial → Attempt pacing and freshness mix and Dial → Call-history chip.

Also fixed here: min_minutes_between_calls was silently dropped on bucket creation — the value in the builder landed on the column default until the first edit saved it.

Dial email compose, and a real SMS template picker on the Message button

The dialer always knows exactly who it's writing to. Until now none of that context reached a template or an activity row on the outbound channels.

Email tab — New Email. A persistent header sits above the thread list with New Email and Refresh. The header survives every body state — loading, load-failure, empty — so a thread list that failed to load isn't a reason to be unable to write. Composing opens the floating compose popup seeded to the lead, because compose already owns rich text, attachments, signatures, schedule-send, draft persistence, and the activity log. Refresh is manual on purpose: a just-sent message takes a moment to appear at the provider, so an automatic post-send refetch would race and show nothing.

Templates now resolve against the lead. The compose toolbar's template picker used to mount with no contact — so every {{contact.*}} tag rendered unfilled, on the one surface in the app that always knows the recipient. It reads draft.templateContact now, seeded through pickContactFields(): the eight fields buildTemplateContext actually reads, not the whole lead (drafts persist to localStorage; a dialer lead carries a details array of every mapped column).

Two email defects fixed along the way:

  • Reply / Reply All / Forward from the dialer's Email tab were not logging. The contact-panel button got this right and had a test guarding it; the tab didn't — and wasn't even passed the table/row ids. dialEmailCompose.js is now the single place all three entry points build the recordContext, so last_activity_at bumps correctly and the bucket queue doesn't re-serve a lead that was just emailed.
  • Forward was a stub — subject only, no body, no original. buildForwardSeed emits the standard "Forwarded message" block with an empty to and a null replyTo (a forward carrying replyTo threads back into the original conversation with In-Reply-To headers). Quoting helpers moved to quoteOriginal.js so a reply and a forward quote the same message identically. Attachments can't ride along — a draft can only send objects already in our bucket under the sending user's prefix — so the Forward warns instead of silently dropping the contract someone meant to forward.

Message button — SMS template picker with OS handoff. The Message button on the dial contact panel now opens a menu instead of firing immediately. Blank message is the first row (today's behaviour); below it, every SMS template rendered against the lead so merge tags are resolved before the text leaves the app. Picking one hands off to the OS texting app with the body prefilled — and also copies the text to the clipboard as a fallback for URL length limits, desktop handlers that ignore the parameter, and other best-effort failure modes. The URI is platform-branched (sms:NUMBER?body=… on Android per RFC 5724; &body= on Apple Messages, which drops the text otherwise) rather than shipping the ?&body= hack that satisfies both and quietly fails on some versions of each.

Nothing sent through the OS handoff is logged — no conversation record, no activity row, no queue bump. That's inherent to leaving the app; the in-app SMS tab on the dial widget remains the path that records.

See Dial → the Dial widget and Dial → Message button.

Records: most-recent-disposition rollup columns

Three system columns land on every record in the org, so the table viewer, the filter engine, and dial buckets can address something that until now only the eligibility engine could see:

ColumnWhat it's for
last_disposition_{id,at,kind,category}Record-header field. Weak for bucket rules — logging an email clobbers the call outcome.
last_call_disposition_{id,at}The one buckets actually want.
last_meeting_disposition_{id,at}Orthogonal to call outcome; meeting dispositions ride on the linked meeting activity.

Backfilled against production: 879 rows with dispositioned activity, agreeing exactly with lead_call_summary and an independently written aggregate. Recompute-from-scratch on write, not a forward-only bump — because a bump goes stale on backdated occurred_at, on a cleared disposition_id, on a deleted activity, and on a deleted value_set_item (which nulls disposition_id via ON DELETE SET NULL).

Opt-in per table. 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 (from migration 122) was retrofitted with the same opt-in flag.

Filter values are picked by label, not by pasting a uuid. Disposition columns store a bare item id that can come from any of the org's disposition sets — the any-kind column takes whichever kind was last — so they resolve by id across every dispositions.* set rather than by a pinned set id.

One system-field registry, four call sites. 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. Eight new columns would have meant a fifth and sixth copy. A new app_hidden.system_field_kind() function returns each field's storage class (uuid / timestamptz / text) or NULL, and a SYSTEM_COLUMNS JS mirror lines up with it. dialer_next_leads also returns the eight new columns — useDialQueue's keyset cursor reads sort keys off the returned row, so sorting on a column the RPC doesn't return would page wrong rather than fail. Parity verified end-to-end against production: 46 / 46 trees produce identical memberships in the JS engine and in filter_tree_predicate.

Three bugs this exposed:

  • The record sheet read system columns as row.data[key], so every DB-managed column (created_at, last_activity_at, …) rendered as an em dash on the sheet.
  • Physical columns were editable in the grid. Committing wrote a same-named key into data, which getRowFieldValue shadows behind the real column and SQL filters never see — invisible on a populated column, but the new rollups are null on most rows, so the divergence was about to become reachable.
  • Toggle rendered as an unnamed button; it now takes an ariaLabel and reports aria-pressed.

See Records → most-recent-disposition rollups.

Templates: copy an SMS picture as a picture, and paste one in

Copying an SMS template already carried its images — inside the text/html clipboard flavour, as base64 data URIs. Gmail, Outlook, and Notes honour that flavour. Messages, WhatsApp, and every native SMS app read text/plain and drop the picture, which is exactly where an SMS template is headed. So the pictures were being copied and still weren't arriving.

Copy picture — each image in the SMS preview now carries its own button that puts the bytes on the clipboard under image/png, the one raster type a browser will let you write and the flavour those apps take. One button per picture, because a clipboard holds one image at a time. Non-PNG (JPEG / WebP / GIF) is re-encoded through a canvas first; an animated GIF copies as its first frame. The fetch is handed to ClipboardItem unresolved, so Safari keeps the write inside the click that started it. Every failure — undecodable format, dead download, no clipboard support — reports in the pane's status line rather than a button that silently did nothing.

Paste to attach — pasting a screenshot into the SMS composer attaches it, matching the drop path, instead of being ignored by the textarea. Reads clipboardData.items first: Safari populates that and leaves .files empty for a copied screenshot. Ordinary text pastes are untouched.

Two bugs found on the way:

  • Dropping an image on an SMS template used to embed it in the body as a data URI. That's the email path — on SMS it typed several hundred KB of base64 into the message text, and that is what would have been sent. SMS now stops after attaching.
  • A rejected file failed silently on SMS — inlineError was only rendered in the email branch.

An image still can't be sent from the app — PitchPrfct's send takes {toNumber, content, fromNumber} with no media field — so the compose-time picker still inserts text alone. Copy picture is the bridge until that API grows MMS.

See Templates → Images as SMS bubbles.

Fixes

Calendar: stop duplicate reminder notifications

One 15:15 appointment was producing six reminder notifications in prod. Two multipliers were stacking:

  • Mirror twins doubled every reminder. A create_event_with_mirror event is two calendar_events rows sharing a mirror_group_id. The store collapsed them for rendering (visibleEvents), but both reminder paths consumed the raw list — so every appointment reminded twice: two toasts, two bell rows, two event_reminders inserts, hence two emails and two pushes. 12 of 13 appointments (92%) in event_reminders were duplicated, all sent — this fired whether or not the app was even open. The visibleEvents memo now sits above the reminder hooks and feeds both.
  • The fired-set was per-tab. The "already reminded" set lived in sessionStorage, scoped to a single tab, so N open tabs each fired independently. It's now localStorage, re-read every 30 s tick so a sibling tab's writes are visible. The key carries the occurrence start and prunes an hour past it — otherwise Google's practice of reusing one event_id across every instance of a recurring event would have silently cancelled every reminder after the first.

Migration 169 adds a partial unique index on (user_id, event_id, event_start) for the calendar category — the backstop for what no browser-local guard can reach (a second device, or a long-open tab on a stale bundle). Duplicate inserts fail with 23505 and are swallowed client-side. Already applied to prod: it collapsed the 109 existing duplicates (folding read state into the keeper first); 542 distinct reminders remain, 0 duplicates. Toasts stay per-browser — someone at two machines should see the reminder on the screen they're looking at, so only the bell is DB-deduped.

Signup: unbreak auth.users INSERT (dropped organizations.plan)

Every INSERT into auth.users was failing in production with column "plan" of relation "organizations" does not exist. Migration 133 dropped public.organizations.plan, but handle_new_user_org() — fired by the AFTER INSERT ON auth.users trigger on_auth_user_created_org — still named it in its INSERT column list. The trigger runs inside the signup transaction, so the failure aborted the whole user insert: no new user could sign up, and every pgTAP file in supabase/tests/ that builds a fixture user via insert into auth.users (16 of them) failed the same way.

supabase/migrations/167_fix_handle_new_user_org_plan_column.sql recreates the function with plan removed. The body is a verbatim copy of live pg_get_functiondef output with exactly one change — because the live definition contained a perform set_config('app.signup_user_id', new.id::text, true) line added by an MCP-applied fix that was never checked into supabase/migrations/. Rebuilding the function from the last tracked version (migration 007) would have silently deleted that line and broken org_payment creation instead. Already applied to prod; this PR gets the file into the repo so the next rebuild starts from the correct body.

Under the hood

  • Migrations 165 / 166 — dial pacing. dial_buckets.call_cadence (ladder) + dial_buckets.queue_mix (aged/fresh interleave). dialer_next_leads returns total_calls; cooldown gate rewritten so one attempts CTE feeds both cap and ladder gates, lower-bounded by the longest rest tier so a lead with no attempt in that window passes them all for free. callCadence.js mirrors the SQL for the builder's live preview.
  • Migration 167 — disposition rollups. Single trigger driving the last_disposition_* / last_call_disposition_* / last_meeting_disposition_* columns (two independent triggers over the same rows can drift). Replaces migration 118's lead_call_summary trigger and adds the UPDATE column list 118 lacked.
  • Migration 168 — system-field registry. app_hidden.system_field_kind() returns a field's storage class; four call sites read from it now instead of maintaining private lists.
  • Migration 169 — reminder dedup index. Partial unique (user_id, event_id, event_start) in the calendar category. Duplicate inserts return 23505 and are swallowed client-side.
  • Migration 167 (auth stream) — handle_new_user_org fix. Yes, the same number as the disposition rollups; they landed on separate migration streams (the auth-hotfix stream and the app-DB stream) so the numbers don't collide at apply time.
  • TemplatePickerPopover gains two opt-in props with existing call sites untouched: anchorRef for portal + fixed coordinates (flipping to whichever side has room; needed because the dial contact panel's overflow-y-auto clips absolute popovers) and leadingAction for the non-template row at the top of the list.
  • ICON.xs sweep. Ten size={ICON.xs} call sites across DatePicker.jsx, OrgSelectorWidget.jsx, dial-stats/DialStatsControls.jsx, and DialContactPanel.jsx were reading a token that doesn't exist — Hugeicons silently fell back to its default size and every affected icon rendered slightly wrong. All 10 → ICON.sm. scripts/check-design-tokens.mjs (pnpm lint:tokens) picked up a second check for ICON.<key> references, and both valid-key sets are now derived from source (text sizes from --text-p88-* in index.css, icon keys from Object.keys(ICON) via a real import of tokens.js) so adding a token in one place is enough. The \bICON\. word boundary deliberately excludes ICON_BUTTON_SIZE.* and WIDGET_ICON.*.
  • buildForwardSeed + quoteOriginal. Extracted quoting helpers so a reply and a forward quote the same message identically.
  • smsDeepLink.js — the platform-branched URI builder used by the dial Message menu.
  • imageClipboard.js — PNG passthrough + canvas conversion + Safari-safe unresolved-fetch ClipboardItem, used by the SMS Copy-picture button.

What's next

  • MMS on the dial / SMS composer. Copy picture is the bridge until PitchPrfct's send grows a media field.
  • Also log the SMS-handoff intent. Nothing sent through the OS Messages app is captured today; happy to record intent (not confirmed send) on the record's timeline if that reads clearer than the current silence.
  • CalendarWeekWidget create/edit path still runs outside the shared useEventActions hook, so an open record sheet won't live-refresh in the moment a slot-click there logs the meeting — it's correct on next open.

See Dial, Templates, and Records → most-recent-disposition rollups.

0.10.0 — Dial home + bucket folders, default-From + scheduled send that ships, and phone/palette search that finds what's there

Dial gets a **card landing page with bucket folders** and stops looping the last lead of an exhausted queue. **Default From account** on every compose window — and **scheduled send** finally runs end-to-end (the worker was never deployed). **Phone search** matches by a canonical digit key instead of stored format, so `(910) 620-0001` and `9106200001` are the same lookup. The command palette stops hiding an entire org behind a legacy `workspaces` row. Clients get a real **source** column backfilled from lineage/phone/email, and the `pitchPrfct` lead-source label is renamed to `selfGen` in place — data, options list, saved views, and conversion snapshots all in one migration.

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.

On this page