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.jsis now the single place all three entry points build therecordContext, solast_activity_atbumps 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.
buildForwardSeedemits the standard "Forwarded message" block with an emptytoand a nullreplyTo(a forward carryingreplyTothreads back into the original conversation withIn-Reply-Toheaders). Quoting helpers moved toquoteOriginal.jsso 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:
| Column | What 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, whichgetRowFieldValueshadows 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. Togglerendered as an unnamed button; it now takes anariaLabeland reportsaria-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 —
inlineErrorwas 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_mirrorevent is twocalendar_eventsrows sharing amirror_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, twoevent_remindersinserts, hence two emails and two pushes. 12 of 13 appointments (92%) inevent_reminderswere duplicated, allsent— this fired whether or not the app was even open. ThevisibleEventsmemo 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 nowlocalStorage, 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 oneevent_idacross 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_leadsreturnstotal_calls; cooldown gate rewritten so oneattemptsCTE 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.jsmirrors 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'slead_call_summarytrigger 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_orgfix. 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. TemplatePickerPopovergains two opt-in props with existing call sites untouched:anchorReffor portal + fixed coordinates (flipping to whichever side has room; needed because the dial contact panel'soverflow-y-autoclips absolute popovers) andleadingActionfor the non-template row at the top of the list.ICON.xssweep. Tensize={ICON.xs}call sites acrossDatePicker.jsx,OrgSelectorWidget.jsx,dial-stats/DialStatsControls.jsx, andDialContactPanel.jsxwere 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 forICON.<key>references, and both valid-key sets are now derived from source (text sizes from--text-p88-*inindex.css, icon keys fromObject.keys(ICON)via a real import oftokens.js) so adding a token in one place is enough. The\bICON\.word boundary deliberately excludesICON_BUTTON_SIZE.*andWIDGET_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-fetchClipboardItem, 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.
CalendarWeekWidgetcreate/edit path still runs outside the shareduseEventActionshook, 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.