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
17 changes: 17 additions & 0 deletions apps/lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ Keep `LIFECYCLE_CRON_ENABLED` unset or set to anything other than the exact valu

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.

## Founder campaign schedule

Campaign email uses `America/Los_Angeles` calendar dates. Enrollment schedules
the first email for 07:00 on the next weekday, even if enrollment happens before
07:00 that day. The second email is due three business days after actual provider
acceptance of the first; the third is due five business days after acceptance of
the second. Weekends are skipped; public holidays are not excluded in V1.
Each target date resolves its own Pacific offset, preserving 07:00 across DST.

Due times are persisted in Growth jobs. The existing cron leases campaign sends
only Monday–Friday during 07:00–08:00 Pacific; final authorization and provider
submission recheck the window. Normal sends begin on the first successful cron
tick after 07:00. Retries can run within that hour; missed windows wait until the
next weekday morning. Stops, mailbox recovery and ambiguous provider acceptance
remain authoritative. Requested fulfillment and internal notifications do not
use this campaign window. Replayed acceptance cannot move later jobs earlier.

## Company evidence capture

Company enrichment uses Dawn. Set `GROWTH_DAWN_ENRICHMENT_ENABLED=false` to pause
Expand Down
6 changes: 5 additions & 1 deletion apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,11 @@ describe('dispatchLifecycleAppOwnedJob', () => {
}
);

it.each(['campaign_disabled', 'delivery_disabled'] as const)(
it.each([
'campaign_disabled',
'delivery_disabled',
'outside_send_window',
] as const)(
'keeps an install-runtime hello deferred while %s',
async (reason) => {
const deps = dependencies({
Expand Down
3 changes: 2 additions & 1 deletion apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ async function dispatchRecipient(
if (result.reason === 'mailbox_recovery_required') return 'recovery_paused';
if (
result.reason === 'campaign_disabled' ||
result.reason === 'delivery_disabled'
result.reason === 'delivery_disabled' ||
result.reason === 'outside_send_window'
) {
const now = dependencies.now();
await dependencies.deferJob(executor, {
Expand Down
39 changes: 39 additions & 0 deletions libs/growth/src/lib/campaign-schedule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
businessMorningAfter,
isCampaignSendWindow,
} from './campaign-schedule.ts';

describe('Pacific campaign calendar', () => {
it.each([
['2026-09-07T12:00:00Z', 1, '2026-09-08T14:00:00.000Z'],
['2026-09-11T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'],
['2026-09-12T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'],
['2026-09-13T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'],
['2026-09-08T14:01:00Z', 3, '2026-09-11T14:00:00.000Z'],
['2026-09-11T14:01:00Z', 5, '2026-09-18T14:00:00.000Z'],
['2026-03-06T15:01:00Z', 1, '2026-03-09T14:00:00.000Z'],
['2026-10-30T14:01:00Z', 1, '2026-11-02T15:00:00.000Z'],
['2026-12-31T15:01:00Z', 1, '2027-01-01T15:00:00.000Z'],
['2026-09-08T01:00:00Z', 1, '2026-09-08T14:00:00.000Z'],
])('schedules %s plus %s weekdays', (input, days, expected) => {
expect(businessMorningAfter(new Date(input), days).toISOString()).toBe(
expected
);
});
it.each([
['2026-09-08T13:59:59Z', false],
['2026-09-08T14:00:00Z', true],
['2026-09-08T14:59:59Z', true],
['2026-09-08T15:00:00Z', false],
['2026-09-12T14:00:00Z', false],
['2026-11-02T15:00:00Z', true],
])('checks the weekday morning send window %s', (input, expected) => {
expect(isCampaignSendWindow(new Date(input))).toBe(expected);
});
it('rejects invalid dates and business-day offsets', () => {
expect(() => businessMorningAfter(new Date('invalid'), 1)).toThrow();
for (const offset of [0, -1, 1.5, Infinity])
expect(() => businessMorningAfter(new Date(), offset)).toThrow();
expect(() => isCampaignSendWindow(new Date('invalid'))).toThrow();
});
});
54 changes: 54 additions & 0 deletions libs/growth/src/lib/campaign-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export const CAMPAIGN_TIME_ZONE = 'America/Los_Angeles';

const calendar = new Intl.DateTimeFormat('en-US', {
timeZone: CAMPAIGN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
});

function parts(date: Date) {
if (!Number.isFinite(date.getTime()))
throw new Error('Invalid campaign date');
const values = Object.fromEntries(
calendar.formatToParts(date).map((p) => [p.type, p.value])
);
return {
year: Number(values['year']),
month: Number(values['month']),
day: Number(values['day']),
hour: Number(values['hour']),
};
}

/** Count local calendar weekdays, always excluding the anchor date. */
export function businessMorningAfter(anchor: Date, businessDays: number): Date {
if (
!Number.isSafeInteger(businessDays) ||
businessDays < 1 ||
businessDays > 366
)
throw new Error('Invalid business-day offset');
const local = parts(anchor);
const day = new Date(Date.UTC(local.year, local.month - 1, local.day));
let remaining = businessDays;
while (remaining > 0) {
day.setUTCDate(day.getUTCDate() + 1);
if (day.getUTCDay() !== 0 && day.getUTCDay() !== 6) remaining--;
}
// 15:00 UTC is 07:00 or 08:00 Pacific. Resolve the target date's own
// offset, not the anchor's, so crossing DST preserves the local hour.
day.setUTCHours(15);
day.setUTCHours(day.getUTCHours() + 7 - parts(day).hour);
return day;
}

export function isCampaignSendWindow(now: Date): boolean {
const local = parts(now);
const weekday = new Date(
Date.UTC(local.year, local.month - 1, local.day)
).getUTCDay();
return weekday !== 0 && weekday !== 6 && local.hour === 7;
}
Loading
Loading