Skip to content

Commit 4ad8893

Browse files
authored
feat(growth): schedule founder emails on Pacific business mornings (#1044)
1 parent 4d25cfb commit 4ad8893

12 files changed

Lines changed: 547 additions & 102 deletions

File tree

apps/lifecycle/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ Keep `LIFECYCLE_CRON_ENABLED` unset or set to anything other than the exact valu
3131

3232
Use [DOGFOOD.md](./DOGFOOD.md) for the provider-free setup, probe, and exact cleanup commands. The harness binds the growth target to a database-owned comment sentinel and binds each authenticated lifecycle health response to Vercel's `VERCEL_DEPLOYMENT_ID`; it also validates lifecycle origins in memory before making requests.
3333

34+
## Founder campaign schedule
35+
36+
Campaign email uses `America/Los_Angeles` calendar dates. Enrollment schedules
37+
the first email for 07:00 on the next weekday, even if enrollment happens before
38+
07:00 that day. The second email is due three business days after actual provider
39+
acceptance of the first; the third is due five business days after acceptance of
40+
the second. Weekends are skipped; public holidays are not excluded in V1.
41+
Each target date resolves its own Pacific offset, preserving 07:00 across DST.
42+
43+
Due times are persisted in Growth jobs. The existing cron leases campaign sends
44+
only Monday–Friday during 07:00–08:00 Pacific; final authorization and provider
45+
submission recheck the window. Normal sends begin on the first successful cron
46+
tick after 07:00. Retries can run within that hour; missed windows wait until the
47+
next weekday morning. Stops, mailbox recovery and ambiguous provider acceptance
48+
remain authoritative. Requested fulfillment and internal notifications do not
49+
use this campaign window. Replayed acceptance cannot move later jobs earlier.
50+
3451
## Company evidence capture
3552

3653
Company enrichment uses Dawn. Set `GROWTH_DAWN_ENRICHMENT_ENABLED=false` to pause

apps/lifecycle/src/campaign/send.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,11 @@ describe('dispatchLifecycleAppOwnedJob', () => {
500500
}
501501
);
502502

503-
it.each(['campaign_disabled', 'delivery_disabled'] as const)(
503+
it.each([
504+
'campaign_disabled',
505+
'delivery_disabled',
506+
'outside_send_window',
507+
] as const)(
504508
'keeps an install-runtime hello deferred while %s',
505509
async (reason) => {
506510
const deps = dependencies({

apps/lifecycle/src/campaign/send.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,8 @@ async function dispatchRecipient(
398398
if (result.reason === 'mailbox_recovery_required') return 'recovery_paused';
399399
if (
400400
result.reason === 'campaign_disabled' ||
401-
result.reason === 'delivery_disabled'
401+
result.reason === 'delivery_disabled' ||
402+
result.reason === 'outside_send_window'
402403
) {
403404
const now = dependencies.now();
404405
await dependencies.deferJob(executor, {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
businessMorningAfter,
3+
isCampaignSendWindow,
4+
} from './campaign-schedule.ts';
5+
6+
describe('Pacific campaign calendar', () => {
7+
it.each([
8+
['2026-09-07T12:00:00Z', 1, '2026-09-08T14:00:00.000Z'],
9+
['2026-09-11T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'],
10+
['2026-09-12T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'],
11+
['2026-09-13T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'],
12+
['2026-09-08T14:01:00Z', 3, '2026-09-11T14:00:00.000Z'],
13+
['2026-09-11T14:01:00Z', 5, '2026-09-18T14:00:00.000Z'],
14+
['2026-03-06T15:01:00Z', 1, '2026-03-09T14:00:00.000Z'],
15+
['2026-10-30T14:01:00Z', 1, '2026-11-02T15:00:00.000Z'],
16+
['2026-12-31T15:01:00Z', 1, '2027-01-01T15:00:00.000Z'],
17+
['2026-09-08T01:00:00Z', 1, '2026-09-08T14:00:00.000Z'],
18+
])('schedules %s plus %s weekdays', (input, days, expected) => {
19+
expect(businessMorningAfter(new Date(input), days).toISOString()).toBe(
20+
expected
21+
);
22+
});
23+
it.each([
24+
['2026-09-08T13:59:59Z', false],
25+
['2026-09-08T14:00:00Z', true],
26+
['2026-09-08T14:59:59Z', true],
27+
['2026-09-08T15:00:00Z', false],
28+
['2026-09-12T14:00:00Z', false],
29+
['2026-11-02T15:00:00Z', true],
30+
])('checks the weekday morning send window %s', (input, expected) => {
31+
expect(isCampaignSendWindow(new Date(input))).toBe(expected);
32+
});
33+
it('rejects invalid dates and business-day offsets', () => {
34+
expect(() => businessMorningAfter(new Date('invalid'), 1)).toThrow();
35+
for (const offset of [0, -1, 1.5, Infinity])
36+
expect(() => businessMorningAfter(new Date(), offset)).toThrow();
37+
expect(() => isCampaignSendWindow(new Date('invalid'))).toThrow();
38+
});
39+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
export const CAMPAIGN_TIME_ZONE = 'America/Los_Angeles';
2+
3+
const calendar = new Intl.DateTimeFormat('en-US', {
4+
timeZone: CAMPAIGN_TIME_ZONE,
5+
year: 'numeric',
6+
month: '2-digit',
7+
day: '2-digit',
8+
hour: '2-digit',
9+
hourCycle: 'h23',
10+
});
11+
12+
function parts(date: Date) {
13+
if (!Number.isFinite(date.getTime()))
14+
throw new Error('Invalid campaign date');
15+
const values = Object.fromEntries(
16+
calendar.formatToParts(date).map((p) => [p.type, p.value])
17+
);
18+
return {
19+
year: Number(values['year']),
20+
month: Number(values['month']),
21+
day: Number(values['day']),
22+
hour: Number(values['hour']),
23+
};
24+
}
25+
26+
/** Count local calendar weekdays, always excluding the anchor date. */
27+
export function businessMorningAfter(anchor: Date, businessDays: number): Date {
28+
if (
29+
!Number.isSafeInteger(businessDays) ||
30+
businessDays < 1 ||
31+
businessDays > 366
32+
)
33+
throw new Error('Invalid business-day offset');
34+
const local = parts(anchor);
35+
const day = new Date(Date.UTC(local.year, local.month - 1, local.day));
36+
let remaining = businessDays;
37+
while (remaining > 0) {
38+
day.setUTCDate(day.getUTCDate() + 1);
39+
if (day.getUTCDay() !== 0 && day.getUTCDay() !== 6) remaining--;
40+
}
41+
// 15:00 UTC is 07:00 or 08:00 Pacific. Resolve the target date's own
42+
// offset, not the anchor's, so crossing DST preserves the local hour.
43+
day.setUTCHours(15);
44+
day.setUTCHours(day.getUTCHours() + 7 - parts(day).hour);
45+
return day;
46+
}
47+
48+
export function isCampaignSendWindow(now: Date): boolean {
49+
const local = parts(now);
50+
const weekday = new Date(
51+
Date.UTC(local.year, local.month - 1, local.day)
52+
).getUTCDay();
53+
return weekday !== 0 && weekday !== 6 && local.hour === 7;
54+
}

0 commit comments

Comments
 (0)