Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/app/(app)/planner/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export default async function PlannerPage() {
This month builds fresh lists: every product’s seeds are re-read.
</p>
)}
{!view.tracksEngagement ? (
<p className="text-ink-muted pt-1 text-xs">
Opens and clicks are not tracked (plain-text email, as the planner recommends), so plays
pick people by delivery and replies: written to, delivered, no answer.
</p>
) : null}
<p className="text-ink-muted pt-1 text-xs">Next month: {view.next.label}</p>
</section>

Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@ export interface PlannerView {
readonly sequence: string;
}>;
readonly next: { readonly period: string; readonly label: string };
readonly tracksEngagement: boolean;
readonly offerings: ReadonlyArray<{
readonly offeringId: string;
readonly name: string;
Expand Down
35 changes: 34 additions & 1 deletion packages/pipeline/src/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ afterEach(() => {
const FEBRUARY = new Date('2026-02-20T15:00:00Z');

/** Jane was emailed in January by the product's campaign and opened it. */
async function janeOpenedInJanuary(db: Client, options: { replied?: boolean } = {}): Promise<void> {
async function janeOpenedInJanuary(
db: Client,
options: { replied?: boolean; tracking?: boolean } = {},
): Promise<void> {
const sentAt = '2026-01-15T15:00:00.000Z';
const stamp = now();
await db.batch([
Expand Down Expand Up @@ -47,6 +50,14 @@ async function janeOpenedInJanuary(db: Client, options: { replied?: boolean } =
args: [SEED.workspaceId, SEED.personId, '2026-01-15T16:00:00.000Z'],
},
]);
if (options.tracking !== false) {
await db.execute({
sql: `INSERT INTO workspace_settings (workspace_id, track_opens, created_at, updated_at)
VALUES (?, 1, ?, ?)
ON CONFLICT (workspace_id) DO UPDATE SET track_opens = 1`,
args: [SEED.workspaceId, stamp, stamp],
});
}
if (options.replied) {
await db.execute({
sql: `INSERT INTO interactions (id, workspace_id, person_id, campaign_id, network, direction,
Expand Down Expand Up @@ -100,6 +111,28 @@ describe('runPlanner', () => {
expect(card?.trigger_signal_id).toBe(SEED.signalId);
});

test('without open or link tracking, delivered and unanswered is the segment', async () => {
seeded = await seedDatabase('planner-blind');
const { db } = seeded;
await janeOpenedInJanuary(db, { tracking: false });
// The open on record is not evidence when nothing is tracked; the
// delivery and the silence are, and that is enough for the play.
await db.execute({ sql: `DELETE FROM email_opens`, args: [] });

const launched = await runPlanner({ db, now: FEBRUARY }, SEED.workspaceId);
expect(launched[0]).toMatchObject({ playKey: 'case_study_non_responders', people: 1 });
expect((await plannerOverview(db, SEED.workspaceId, FEBRUARY)).tracksEngagement).toBe(false);
});

test('with tracking on, someone who never opened is not "engaged"', async () => {
seeded = await seedDatabase('planner-tracked-unopened');
const { db } = seeded;
await janeOpenedInJanuary(db);
await db.execute({ sql: `DELETE FROM email_opens`, args: [] });

expect(await runPlanner({ db, now: FEBRUARY }, SEED.workspaceId)).toEqual([]);
});

test('someone who replied is not a non-responder', async () => {
seeded = await seedDatabase('planner-replied');
const { db } = seeded;
Expand Down
39 changes: 33 additions & 6 deletions packages/pipeline/src/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,12 +302,26 @@ export async function segmentPeople(
const monthStart = new Date(Date.UTC(year, at.getUTCMonth(), 1)).toISOString();
const excluded = segment.excludePlays ?? [];

const engagementSql: Record<Segment['engagement'], string> = {
engaged_no_reply: '(opened = 1 OR clicked = 1) AND replied = 0',
no_reply: 'replied = 0',
unengaged: 'opened = 0 AND clicked = 0 AND replied = 0',
engaged: '(opened = 1 OR clicked = 1 OR positive = 1)',
};
// Opens and clicks are only evidence when the workspace tracks them, and
// both are off by default: the planner's own rule is a plain-text first
// email with no links or images. Without them "opened but never replied"
// is unknowable, so each segment falls back to what is known — written to,
// delivered, no reply — and the plays' exclusions keep one person out of
// two plays. Nobody who said no or asked to stop is ever in a segment.
const blind = !(await tracksEngagement(db, workspaceId));
const engagementSql: Record<Segment['engagement'], string> = blind
? {
engaged_no_reply: 'replied = 0',
no_reply: 'replied = 0',
unengaged: 'replied = 0',
engaged: '(positive = 1 OR replied = 0)',
}
: {
engaged_no_reply: '(opened = 1 OR clicked = 1) AND replied = 0',
no_reply: 'replied = 0',
unengaged: 'opened = 0 AND clicked = 0 AND replied = 0',
engaged: '(opened = 1 OR clicked = 1 OR positive = 1)',
};

const rows = await queryAll<{ person_id: string }>(
db,
Expand Down Expand Up @@ -389,6 +403,16 @@ export async function segmentPeople(
return rows.map((row) => row.person_id);
}

/** Whether opens or clicks are recorded at all, i.e. whether they can be read as engagement. */
export async function tracksEngagement(db: Client, workspaceId: string): Promise<boolean> {
const row = await queryOne<{ track_opens: number | null; track_links: number | null }>(
db,
'SELECT track_opens, track_links FROM workspace_settings WHERE workspace_id = ?',
[workspaceId],
);
return Number(row?.track_opens ?? 0) === 1 || Number(row?.track_links ?? 0) === 1;
}

async function sourceCampaign(db: Client, offeringId: string): Promise<SourceCampaign | undefined> {
// The product's busiest non-planner campaign sets the approval mode, voice
// and limits: a product run on autopilot gets plays on autopilot, a product
Expand Down Expand Up @@ -469,6 +493,8 @@ export interface PlannerOverview {
readonly sequence: string;
}>;
readonly next: { readonly period: string; readonly label: string };
/** False when neither opens nor clicks are tracked, so segments use delivery and replies. */
readonly tracksEngagement: boolean;
readonly offerings: ReadonlyArray<{
readonly offeringId: string;
readonly name: string;
Expand Down Expand Up @@ -526,6 +552,7 @@ export async function plannerOverview(
sequence: play.sequence,
})),
next: { period: plannerPeriod(nextAt), label: next.label },
tracksEngagement: await tracksEngagement(db, workspaceId),
offerings: offerings.map((offering) => ({
offeringId: offering.id,
name: offering.name,
Expand Down
Loading